diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..edfde62 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,49 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get next version + uses: reecetech/version-increment@2024.10.1 + id: version + with: + scheme: semver + increment: patch + + - run: git tag ${{ steps.version.outputs.version }} + - run: git push origin ${{ steps.version.outputs.version }} + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + # - name: Restore dependencies + # run: dotnet restore CleanAspire.sln + # - name: Build + # run: dotnet build CleanAspire.sln --no-restore + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Publish Docker image using .NET SDK + run: | + dotnet publish "src/CleanAspire.AppHost/CleanAspire.AppHost.csproj" -c Release /p:PublishProfile=DefaultContainer /p:ContainerRepository=${{ secrets.DOCKER_USERNAME }}/cleanaspire /p:ContainerTag=${{ steps.version.outputs.version }} + + - name: Push Docker image to Docker Hub + run: | + docker push ${{ secrets.DOCKER_USERNAME }}/cleanaspire:${{ steps.version.outputs.version }} diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a0b23ec..371c628 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,8 +18,8 @@ jobs: with: dotnet-version: 9.0.x - name: Restore dependencies - run: dotnet restore CleanAspire.slnx + run: dotnet restore CleanAspire.sln - name: Build - run: dotnet build CleanAspire.slnx --no-restore + run: dotnet build CleanAspire.sln --no-restore - name: Run tests run: dotnet test ./tests/CleanAspire.Tests/CleanAspire.Tests.csproj --no-build --verbosity normal diff --git a/CleanAspire.sln b/CleanAspire.sln new file mode 100644 index 0000000..5e57fe4 --- /dev/null +++ b/CleanAspire.sln @@ -0,0 +1,106 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35507.96 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.Api", "src\CleanAspire.Api\CleanAspire.Api.csproj", "{DED5E19F-DB6B-C4EE-E692-CFFE0619C173}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.AppHost", "src\CleanAspire.AppHost\CleanAspire.AppHost.csproj", "{B92D7A82-8ECA-A291-3CB2-F3814065699C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.Application", "src\CleanAspire.Application\CleanAspire.Application.csproj", "{06710E4A-8503-48B5-B25B-D1056A7414EB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.ClientApp", "src\CleanAspire.ClientApp\CleanAspire.ClientApp.csproj", "{094B3FA8-466F-A58F-3DD9-8B1DF8A41758}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.Domain", "src\CleanAspire.Domain\CleanAspire.Domain.csproj", "{DFAF6933-E1D7-4EAC-B854-FE12BC19D9EF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.Infrastructure", "src\CleanAspire.Infrastructure\CleanAspire.Infrastructure.csproj", "{ABE1716E-889C-48B6-8AA0-51D688F3C480}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.ServiceDefaults", "src\CleanAspire.ServiceDefaults\CleanAspire.ServiceDefaults.csproj", "{0DFC6E30-9932-5041-9FC8-75D8303F5BEE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migrators", "Migrators", "{C983071D-42D7-4326-A379-CE622E29D307}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migrators.MSSQL", "src\Migrators\Migrators.MSSQL\Migrators.MSSQL.csproj", "{2E671CBD-FCC7-4BE8-B3AA-71C296AC5317}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migrators.PostgreSQL", "src\Migrators\Migrators.PostgreSQL\Migrators.PostgreSQL.csproj", "{7D6E47CC-90F1-4C19-AF96-1DE7EED7928C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migrators.SQLite", "src\Migrators\Migrators.SQLite\Migrators.SQLite.csproj", "{67226F2C-5D75-4B76-B5F8-E42A4BC1BBB1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanAspire.Tests", "tests\CleanAspire.Tests\CleanAspire.Tests.csproj", "{184DD222-E87D-65E3-4E4F-ADC8680E2D81}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DED5E19F-DB6B-C4EE-E692-CFFE0619C173}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DED5E19F-DB6B-C4EE-E692-CFFE0619C173}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DED5E19F-DB6B-C4EE-E692-CFFE0619C173}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DED5E19F-DB6B-C4EE-E692-CFFE0619C173}.Release|Any CPU.Build.0 = Release|Any CPU + {B92D7A82-8ECA-A291-3CB2-F3814065699C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B92D7A82-8ECA-A291-3CB2-F3814065699C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B92D7A82-8ECA-A291-3CB2-F3814065699C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B92D7A82-8ECA-A291-3CB2-F3814065699C}.Release|Any CPU.Build.0 = Release|Any CPU + {06710E4A-8503-48B5-B25B-D1056A7414EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06710E4A-8503-48B5-B25B-D1056A7414EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06710E4A-8503-48B5-B25B-D1056A7414EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06710E4A-8503-48B5-B25B-D1056A7414EB}.Release|Any CPU.Build.0 = Release|Any CPU + {094B3FA8-466F-A58F-3DD9-8B1DF8A41758}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {094B3FA8-466F-A58F-3DD9-8B1DF8A41758}.Debug|Any CPU.Build.0 = Debug|Any CPU + {094B3FA8-466F-A58F-3DD9-8B1DF8A41758}.Release|Any CPU.ActiveCfg = Release|Any CPU + {094B3FA8-466F-A58F-3DD9-8B1DF8A41758}.Release|Any CPU.Build.0 = Release|Any CPU + {DFAF6933-E1D7-4EAC-B854-FE12BC19D9EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFAF6933-E1D7-4EAC-B854-FE12BC19D9EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFAF6933-E1D7-4EAC-B854-FE12BC19D9EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFAF6933-E1D7-4EAC-B854-FE12BC19D9EF}.Release|Any CPU.Build.0 = Release|Any CPU + {ABE1716E-889C-48B6-8AA0-51D688F3C480}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABE1716E-889C-48B6-8AA0-51D688F3C480}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABE1716E-889C-48B6-8AA0-51D688F3C480}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABE1716E-889C-48B6-8AA0-51D688F3C480}.Release|Any CPU.Build.0 = Release|Any CPU + {0DFC6E30-9932-5041-9FC8-75D8303F5BEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DFC6E30-9932-5041-9FC8-75D8303F5BEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DFC6E30-9932-5041-9FC8-75D8303F5BEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DFC6E30-9932-5041-9FC8-75D8303F5BEE}.Release|Any CPU.Build.0 = Release|Any CPU + {2E671CBD-FCC7-4BE8-B3AA-71C296AC5317}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E671CBD-FCC7-4BE8-B3AA-71C296AC5317}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E671CBD-FCC7-4BE8-B3AA-71C296AC5317}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E671CBD-FCC7-4BE8-B3AA-71C296AC5317}.Release|Any CPU.Build.0 = Release|Any CPU + {7D6E47CC-90F1-4C19-AF96-1DE7EED7928C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D6E47CC-90F1-4C19-AF96-1DE7EED7928C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D6E47CC-90F1-4C19-AF96-1DE7EED7928C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D6E47CC-90F1-4C19-AF96-1DE7EED7928C}.Release|Any CPU.Build.0 = Release|Any CPU + {67226F2C-5D75-4B76-B5F8-E42A4BC1BBB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67226F2C-5D75-4B76-B5F8-E42A4BC1BBB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67226F2C-5D75-4B76-B5F8-E42A4BC1BBB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67226F2C-5D75-4B76-B5F8-E42A4BC1BBB1}.Release|Any CPU.Build.0 = Release|Any CPU + {184DD222-E87D-65E3-4E4F-ADC8680E2D81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {184DD222-E87D-65E3-4E4F-ADC8680E2D81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {184DD222-E87D-65E3-4E4F-ADC8680E2D81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {184DD222-E87D-65E3-4E4F-ADC8680E2D81}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DED5E19F-DB6B-C4EE-E692-CFFE0619C173} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {B92D7A82-8ECA-A291-3CB2-F3814065699C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {06710E4A-8503-48B5-B25B-D1056A7414EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {094B3FA8-466F-A58F-3DD9-8B1DF8A41758} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {DFAF6933-E1D7-4EAC-B854-FE12BC19D9EF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {ABE1716E-889C-48B6-8AA0-51D688F3C480} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {0DFC6E30-9932-5041-9FC8-75D8303F5BEE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C983071D-42D7-4326-A379-CE622E29D307} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2E671CBD-FCC7-4BE8-B3AA-71C296AC5317} = {C983071D-42D7-4326-A379-CE622E29D307} + {7D6E47CC-90F1-4C19-AF96-1DE7EED7928C} = {C983071D-42D7-4326-A379-CE622E29D307} + {67226F2C-5D75-4B76-B5F8-E42A4BC1BBB1} = {C983071D-42D7-4326-A379-CE622E29D307} + {184DD222-E87D-65E3-4E4F-ADC8680E2D81} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 36f915a..c0553ae 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ ## CleanAspire - Modern .NET 9 Minimal API + Blazor WebAssembly PWA Template +[![.NET](https://github.com/neozhu/cleanaspire/actions/workflows/dotnet.yml/badge.svg)](https://github.com/neozhu/cleanaspire/actions/workflows/dotnet.yml) +[![CodeQL](https://github.com/neozhu/cleanaspire/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/neozhu/cleanaspire/actions/workflows/github-code-scanning/codeql) + ![blazorclient](https://github.com/user-attachments/assets/013b167b-59fa-42d7-a2f7-ffec301c4e11) ### Overview diff --git a/src/CleanAspire.Api/CleanAspire.Api.csproj b/src/CleanAspire.Api/CleanAspire.Api.csproj index a8cd96c..d5418ef 100644 --- a/src/CleanAspire.Api/CleanAspire.Api.csproj +++ b/src/CleanAspire.Api/CleanAspire.Api.csproj @@ -1,28 +1,34 @@ - - net9.0 - enable - enable - CleanAspire.Api - CleanAspire.Api - - - - - - - - - - - - - - - - - - + + net9.0 + enable + enable + CleanAspire.Api + CleanAspire.Api + + + + $(MSBuildProjectDirectory) + + + + + + + + + + + + + + + + + + + + diff --git a/src/CleanAspire.Api/CleanAspire.Api.json b/src/CleanAspire.Api/CleanAspire.Api.json new file mode 100644 index 0000000..dacb0fc --- /dev/null +++ b/src/CleanAspire.Api/CleanAspire.Api.json @@ -0,0 +1,704 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "CleanAspire.Api | v1", + "version": "1.0.0" + }, + "paths": { + "/weatherforecast": { + "get": { + "tags": [ + "Weather" + ], + "summary": "Get the weather forecast for the next 5 days.", + "description": "Returns an array of weather forecast data including the date, temperature, and weather summary for the next 5 days. Each forecast entry provides information about the expected temperature and a brief summary of the weather conditions.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherForecast" + } + } + } + } + } + } + } + }, + "/logout": { + "post": { + "tags": [ + "Authentication", + "Identity Management" + ], + "summary": "Log out the current user.", + "description": "Logs out the currently authenticated user by signing them out of the system. This endpoint requires the user to be authorized before calling, and returns an HTTP 200 OK response upon successful logout.", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "Identity.Application": [ ] + } + ] + } + }, + "/register": { + "post": { + "tags": [ + "CleanAspire.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/login": { + "post": { + "tags": [ + "CleanAspire.Api" + ], + "parameters": [ + { + "name": "useCookies", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "useSessionCookies", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenResponse" + } + } + } + } + } + } + }, + "/refresh": { + "post": { + "tags": [ + "CleanAspire.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenResponse" + } + } + } + } + } + } + }, + "/confirmEmail": { + "get": { + "tags": [ + "CleanAspire.Api" + ], + "operationId": "MapIdentityApi-/confirmEmail", + "parameters": [ + { + "name": "userId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "code", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "changedEmail", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/resendConfirmationEmail": { + "post": { + "tags": [ + "CleanAspire.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendConfirmationEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/forgotPassword": { + "post": { + "tags": [ + "CleanAspire.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/resetPassword": { + "post": { + "tags": [ + "CleanAspire.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/manage/2fa": { + "post": { + "tags": [ + "CleanAspire.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFactorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFactorResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "Identity.Application": [ ] + } + ] + } + }, + "/manage/info": { + "get": { + "tags": [ + "CleanAspire.Api" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "Identity.Application": [ ] + } + ] + }, + "post": { + "tags": [ + "CleanAspire.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "Identity.Application": [ ] + } + ] + } + } + }, + "components": { + "schemas": { + "AccessTokenResponse": { + "required": [ + "accessToken", + "expiresIn", + "refreshToken" + ], + "type": "object", + "properties": { + "tokenType": { + "type": "string", + "nullable": true + }, + "accessToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int64" + }, + "refreshToken": { + "type": "string" + } + } + }, + "ForgotPasswordRequest": { + "required": [ + "email" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "HttpValidationProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "InfoRequest": { + "type": "object", + "properties": { + "newEmail": { + "type": "string", + "nullable": true + }, + "newPassword": { + "type": "string", + "nullable": true + }, + "oldPassword": { + "type": "string", + "nullable": true + } + } + }, + "InfoResponse": { + "required": [ + "email", + "isEmailConfirmed" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "isEmailConfirmed": { + "type": "boolean" + } + } + }, + "LoginRequest": { + "required": [ + "email", + "password" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "twoFactorCode": { + "type": "string", + "nullable": true + }, + "twoFactorRecoveryCode": { + "type": "string", + "nullable": true + } + }, + "example": { + "email": "administrator", + "password": "P@ssw0rd!" + } + }, + "RefreshRequest": { + "required": [ + "refreshToken" + ], + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + } + } + }, + "RegisterRequest": { + "required": [ + "email", + "password" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "example": { + "email": "Ima29@yahoo.com", + "password": "P@ssw0rd!" + } + }, + "ResendConfirmationEmailRequest": { + "required": [ + "email" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "ResetPasswordRequest": { + "required": [ + "email", + "resetCode", + "newPassword" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "resetCode": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "TwoFactorRequest": { + "type": "object", + "properties": { + "enable": { + "type": "boolean", + "nullable": true + }, + "twoFactorCode": { + "type": "string", + "nullable": true + }, + "resetSharedKey": { + "type": "boolean" + }, + "resetRecoveryCodes": { + "type": "boolean" + }, + "forgetMachine": { + "type": "boolean" + } + } + }, + "TwoFactorResponse": { + "required": [ + "sharedKey", + "recoveryCodesLeft", + "isTwoFactorEnabled", + "isMachineRemembered" + ], + "type": "object", + "properties": { + "sharedKey": { + "type": "string" + }, + "recoveryCodesLeft": { + "type": "integer", + "format": "int32" + }, + "recoveryCodes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "isTwoFactorEnabled": { + "type": "boolean" + }, + "isMachineRemembered": { + "type": "boolean" + } + } + }, + "WeatherForecast": { + "required": [ + "date", + "temperatureC", + "summary" + ], + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "temperatureC": { + "type": "integer", + "format": "int32" + }, + "summary": { + "type": "string", + "nullable": true + }, + "temperatureF": { + "type": "integer", + "format": "int32" + } + } + } + }, + "securitySchemes": { + "Identity.Application": { + "type": "http", + "description": "Use this cookie to authenticate the user." + } + } + }, + "tags": [ + { + "name": "Weather" + }, + { + "name": "Authentication" + }, + { + "name": "Identity Management" + }, + { + "name": "CleanAspire.Api" + } + ] +} \ No newline at end of file diff --git a/src/CleanAspire.Api/CleanAspireDb.db b/src/CleanAspire.Api/CleanAspireDb.db index e5d1a8b..fc50efc 100644 Binary files a/src/CleanAspire.Api/CleanAspireDb.db and b/src/CleanAspire.Api/CleanAspireDb.db differ diff --git a/src/CleanAspire.Api/CleanAspireDb.db-shm b/src/CleanAspire.Api/CleanAspireDb.db-shm index 46e8aa9..088c0f2 100644 Binary files a/src/CleanAspire.Api/CleanAspireDb.db-shm and b/src/CleanAspire.Api/CleanAspireDb.db-shm differ diff --git a/src/CleanAspire.Api/CleanAspireDb.db-wal b/src/CleanAspire.Api/CleanAspireDb.db-wal index 354b097..524434e 100644 Binary files a/src/CleanAspire.Api/CleanAspireDb.db-wal and b/src/CleanAspire.Api/CleanAspireDb.db-wal differ diff --git a/src/CleanAspire.Api/Identity/EmailSender.cs b/src/CleanAspire.Api/Identity/EmailSender.cs new file mode 100644 index 0000000..565409a --- /dev/null +++ b/src/CleanAspire.Api/Identity/EmailSender.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using MailKit.Net.Smtp; +using Microsoft.AspNetCore.Identity.UI.Services; +using MimeKit; + +namespace CleanAspire.Api.Identity; + +public class EmailSender : IEmailSender +{ + + private readonly SmtpClientOptions _smtpOptions; + public EmailSender(IConfiguration configuration) + { + _smtpOptions = configuration.GetSection("SmtpClientOptions").Get(); + } + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("", _smtpOptions.DefaultFromEmail)); + message.To.Add(new MailboxAddress("", email)); + message.Subject = subject; + + var bodyBuilder = new BodyBuilder + { + HtmlBody = htmlMessage + }; + message.Body = bodyBuilder.ToMessageBody(); + + using var smtpClient = new SmtpClient(); + if (_smtpOptions.UsePickupDirectory && !string.IsNullOrEmpty(_smtpOptions.MailPickupDirectory)) + { + smtpClient.LocalDomain = "localhost"; + await smtpClient.SendAsync(message); + } + else + { + await smtpClient.ConnectAsync(_smtpOptions.Server, _smtpOptions.Port, _smtpOptions.UseSsl); + + if (_smtpOptions.RequiresAuthentication) + { + await smtpClient.AuthenticateAsync(_smtpOptions.User, _smtpOptions.Password); + } + + await smtpClient.SendAsync(message); + await smtpClient.DisconnectAsync(true); + } + } +} + +public class SmtpClientOptions +{ + public const string Key = nameof(SmtpClientOptions); + public string Server { get; set; } + public int Port { get; set; } = 25; + public string User { get; set; } + public string Password { get; set; } + public bool UseSsl { get; set; } = false; + public bool RequiresAuthentication { get; set; } = true; + public string PreferredEncoding { get; set; } + public bool UsePickupDirectory { get; set; } = false; + public string MailPickupDirectory { get; set; } + public object SocketOptions { get; set; } + public string DefaultFromEmail { get; set; } +} diff --git a/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs b/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs index b7b133a..769fd45 100644 --- a/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs +++ b/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs @@ -16,7 +16,10 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints(thi { await signInManager.SignOutAsync(); return Results.Ok(); - }).RequireAuthorization(); + }).RequireAuthorization() + .WithTags("Authentication", "Identity Management") + .WithSummary("Log out the current user.") + .WithDescription("Logs out the currently authenticated user by signing them out of the system. This endpoint requires the user to be authorized before calling, and returns an HTTP 200 OK response upon successful logout."); return endpoints; } diff --git a/src/CleanAspire.Api/OpenApiTransformersExtensions.cs b/src/CleanAspire.Api/OpenApiTransformersExtensions.cs new file mode 100644 index 0000000..9b6a053 --- /dev/null +++ b/src/CleanAspire.Api/OpenApiTransformersExtensions.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Bogus; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Data; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +namespace CleanAspire.Api; + +public static class OpenApiTransformersExtensions +{ + public static OpenApiOptions UseCookieAuthentication(this OpenApiOptions options) + { + var scheme = new OpenApiSecurityScheme + { + Name = IdentityConstants.ApplicationScheme, + Type = SecuritySchemeType.Http, + Description = "Use this cookie to authenticate the user.", + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = IdentityConstants.ApplicationScheme + } + }; + options.AddDocumentTransformer((document, context, ct) => + { + document.Components ??= new(); + document.Components.SecuritySchemes.Add(IdentityConstants.ApplicationScheme, scheme); + return Task.CompletedTask; + }); + options.AddOperationTransformer((operation, context, ct) => + { + if (context.Description.ActionDescriptor.EndpointMetadata.OfType().Any()) + { + operation.Security = [new() { [scheme] = [] }]; + } + return Task.CompletedTask; + }); + return options; + } + public static OpenApiOptions UseExamples(this OpenApiOptions options) + { + options.AddSchemaTransformer(new ExampleChemaTransformer()); + return options; + } + + + + private class ExampleChemaTransformer : IOpenApiSchemaTransformer + { + private static readonly Faker _faker = new(); + private static readonly Dictionary _examples = []; + + public ExampleChemaTransformer() + { + _examples[typeof(LoginRequest)]=new OpenApiObject + { + ["email"] = new OpenApiString("administrator"), + ["password"] = new OpenApiString("P@ssw0rd!") + }; + _examples[typeof(RegisterRequest)] = new OpenApiObject + { + ["email"] = new OpenApiString(_faker.Internet.Email()), + ["password"] = new OpenApiString("P@ssw0rd!") + }; + + } + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var type = context.JsonTypeInfo.Type; + if (_examples.ContainsKey(type)) + { + schema.Example = _examples[type]; + } + return Task.CompletedTask; + } + } +} diff --git a/src/CleanAspire.Api/Program.cs b/src/CleanAspire.Api/Program.cs index a5d68c3..2c3c304 100644 --- a/src/CleanAspire.Api/Program.cs +++ b/src/CleanAspire.Api/Program.cs @@ -1,4 +1,5 @@ -using CleanAspire.Api; +using System.Text.Json.Serialization; +using CleanAspire.Api; using CleanAspire.Application; using CleanAspire.Application.Common.Interfaces; using CleanAspire.Application.Common.Services; @@ -12,19 +13,27 @@ using Microsoft.Extensions.DependencyInjection; using Mono.TextTemplating; using Scalar.AspNetCore; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +using CleanAspire.Api.Identity; + var builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpContextAccessor(); + builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme).AddIdentityCookies(); builder.Services.AddAuthorizationBuilder(); +builder.Services.AddTransient(); -builder.Services.AddDatabase(builder.Configuration); -builder.Services.AddIdentityCore() +builder.Services.AddIdentityCore(options => +{ + options.SignIn.RequireConfirmedEmail = true; +}) .AddEntityFrameworkStores() .AddApiEndpoints(); // add a CORS policy for the client @@ -36,7 +45,19 @@ .AllowAnyHeader() .AllowCredentials())); -builder.Services.AddOpenApi(); +builder.Services.AddOpenApi(options => +{ + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0; + options.UseCookieAuthentication(); + options.UseExamples(); +}); +builder.Services.ConfigureHttpJsonOptions(options => +{ + // Don't serialize null values + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + // Pretty print JSON + options.SerializerOptions.WriteIndented = true; +}); builder.Services.AddServiceDiscovery(); // Add service defaults & Aspire client integrations. @@ -54,11 +75,9 @@ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; app.UseCors("wasm"); -app.MapGet("/weatherforecast", async (IApplicationDbContext db) => +app.MapGet("/weatherforecast", () => { - var product = new Product() { Id = Guid.CreateVersion7().ToString(), Name = "test" + Guid.CreateVersion7().ToString(), Description = "test", Currency = "USD", Price = 100, Quantity = 99, UOM = "EA" }; - db.Products.Add(product); - await db.SaveChangesAsync(); + var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast ( @@ -68,7 +87,9 @@ )) .ToArray(); return forecast; -}); +}).WithTags("Weather") + .WithSummary("Get the weather forecast for the next 5 days.") + .WithDescription("Returns an array of weather forecast data including the date, temperature, and weather summary for the next 5 days. Each forecast entry provides information about the expected temperature and a brief summary of the weather conditions."); app.Use(async (context, next) => { diff --git a/src/CleanAspire.Api/appsettings.Development.json b/src/CleanAspire.Api/appsettings.Development.json index 0c208ae..42f6e4c 100644 --- a/src/CleanAspire.Api/appsettings.Development.json +++ b/src/CleanAspire.Api/appsettings.Development.json @@ -4,5 +4,17 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "SmtpClientOptions": { + "Server": "live.smtp.mailtrap.io", + "Port": 2525, + "User": "api", + "Password": "917a207d4e4da26f0529582c7555971c", + "UseSsl": false, + "RequiresAuthentication": true, + "PreferredEncoding": "", + "UsePickupDirectory": false, + "MailPickupDirectory": "", + "SocketOptions": null } } diff --git a/src/CleanAspire.Api/appsettings.json b/src/CleanAspire.Api/appsettings.json index 8e84f5d..b6ac53f 100644 --- a/src/CleanAspire.Api/appsettings.json +++ b/src/CleanAspire.Api/appsettings.json @@ -14,5 +14,18 @@ //"DBProvider": "postgresql", //"ConnectionString": "Server=127.0.0.1;Database=CleanAspireDb;User Id=root;Password=root;Port=5432" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "SmtpClientOptions": { + "Server": "", + "Port": 25, + "User": "", + "Password": "", + "UseSsl": false, + "RequiresAuthentication": true, + "PreferredEncoding": "", + "UsePickupDirectory": false, + "MailPickupDirectory": "", + "SocketOptions": null, + "DefaultFromEmail": "noreply@blazorserver.com" + } } diff --git a/src/CleanAspire.AppHost/CleanAspire.AppHost.csproj b/src/CleanAspire.AppHost/CleanAspire.AppHost.csproj index f0d5807..32bed02 100644 --- a/src/CleanAspire.AppHost/CleanAspire.AppHost.csproj +++ b/src/CleanAspire.AppHost/CleanAspire.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe @@ -8,7 +8,11 @@ enable enable true + true + true + cleanaspire ac63aff4-1f44-46a9-9c5d-0b26b517e142 + @@ -20,8 +24,11 @@ - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/CleanAspire.ClientApp/Pages/Account/ForgetPassword.razor b/src/CleanAspire.ClientApp/Pages/Account/ForgetPassword.razor index 051b8f9..e6e615e 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/ForgetPassword.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/ForgetPassword.razor @@ -22,12 +22,12 @@ @code { private ForgetPasswordModel model = new(); - + private async Task OnValidSubmit(EditContext context) { try { - // await PocketbaseClient.Auth.User.RequestPasswordResetAsync(model.Email); + await ApiClient.ForgotPassword.PostAsync(new ForgotPasswordRequest() { Email = model.Email }); Navigation.NavigateTo("/account/forgetpasswordsuccessful"); } catch (Exception e) diff --git a/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor b/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor index a75d211..5d05673 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor @@ -23,7 +23,7 @@
- @L["remember me"] + @L["remember me"] @L["forget password?"]
@L["Sign In"] @@ -47,7 +47,7 @@ try { - var result = await IdentityManagement.LoginAsync(new Api.Client.Models.LoginRequest() { Email = model.Username, Password = model.Password }); + var result = await IdentityManagement.LoginAsync(new Api.Client.Models.LoginRequest() { Email = model.Username, Password = model.Password }, model.RememberMe); StateHasChanged(); } @@ -83,6 +83,7 @@ [StringLength(30, ErrorMessage = "Password must be at least 6 characters long.", MinimumLength = 6)] [RegularExpression(@"^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).{6,}$", ErrorMessage = "Password must be at least 6 characters long and contain at least one letter, one number, and one special character.")] public string Password { get; set; } = string.Empty; + public bool RememberMe { get; set; } } } diff --git a/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor b/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor index e08474c..e772a34 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor @@ -15,12 +15,12 @@
- +
@L["I agree to the terms and privacy"]
- @L["Signup"] + @L["Signup"]
@@ -33,12 +33,8 @@ { try { - // var result = await PocketbaseClient.Auth.User.CreateAsync(model.Email, model.Password, model.ConfirmPassword); - // if (result is not null && result.Verified == false) - // { - // await PocketbaseClient.Auth.User.RequestVerificationAsync(model.Email); - // Navigation.NavigateTo("/account/signupsuccessful"); - // } + var result = await ApiClient.Register.PostAsync(new RegisterRequest() { Email = model.Email, Password = model.Password }); + Navigation.NavigateTo("/account/signupsuccessful"); } catch (Exception e) { diff --git a/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs b/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs index 1e90ca3..13792b9 100644 --- a/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs +++ b/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs @@ -48,24 +48,28 @@ public override async Task GetAuthenticationStateAsync() return new AuthenticationState(user); } - public async Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) + public async Task LoginAsync(LoginRequest request, bool remember = false, CancellationToken cancellationToken = default) { try { // login with cookies - await apiClient.Login.PostAsync(request, options => options.QueryParameters.UseCookies = true, cancellationToken); - // need to refresh auth state + await apiClient.Login.PostAsync(request, options => + { + options.QueryParameters.UseCookies = remember; + options.QueryParameters.UseSessionCookies = !remember; + }, cancellationToken); + // need to refresh auth state NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - + } catch { } return new AccessTokenResponse(); - + } public async Task LogoutAsync(CancellationToken cancellationToken = default) { - await apiClient.Logout.PostAsync(cancellationToken:cancellationToken); + await apiClient.Logout.PostAsync(cancellationToken: cancellationToken); // need to refresh auth state NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } diff --git a/src/CleanAspire.ClientApp/Services/Identity/IIdentityManagement.cs b/src/CleanAspire.ClientApp/Services/Identity/IIdentityManagement.cs index d3395f6..1bf2778 100644 --- a/src/CleanAspire.ClientApp/Services/Identity/IIdentityManagement.cs +++ b/src/CleanAspire.ClientApp/Services/Identity/IIdentityManagement.cs @@ -8,7 +8,7 @@ namespace CleanAspire.ClientApp.Services.Identity; public interface IIdentityManagement { - public Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default); + public Task LoginAsync(LoginRequest request,bool remember=false, CancellationToken cancellationToken = default); public Task LogoutAsync(CancellationToken cancellationToken = default); public Task RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default); } diff --git a/src/CleanAspire.Domain/CleanAspire.Domain.csproj b/src/CleanAspire.Domain/CleanAspire.Domain.csproj index f0dd219..1440705 100644 --- a/src/CleanAspire.Domain/CleanAspire.Domain.csproj +++ b/src/CleanAspire.Domain/CleanAspire.Domain.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/CleanAspire.Infrastructure/CleanAspire.Infrastructure.csproj b/src/CleanAspire.Infrastructure/CleanAspire.Infrastructure.csproj index e9a9fa4..31e4de2 100644 --- a/src/CleanAspire.Infrastructure/CleanAspire.Infrastructure.csproj +++ b/src/CleanAspire.Infrastructure/CleanAspire.Infrastructure.csproj @@ -12,15 +12,15 @@ - - - - - - - - - + + + + + + + + + diff --git a/src/CleanAspire.Infrastructure/DependencyInjection.cs b/src/CleanAspire.Infrastructure/DependencyInjection.cs index 8aa21bc..2a78e59 100644 --- a/src/CleanAspire.Infrastructure/DependencyInjection.cs +++ b/src/CleanAspire.Infrastructure/DependencyInjection.cs @@ -17,14 +17,18 @@ using CleanAspire.Infrastructure.Persistence.Interceptors; using CleanAspire.Infrastructure.Configurations; using Microsoft.Extensions.Options; -using Microsoft.AspNetCore.Identity; using CleanAspire.Infrastructure.Services; using Microsoft.Extensions.Hosting; using CleanAspire.Application.Common.Services; + + namespace CleanAspire.Infrastructure; public static class DependencyInjection { + private const string SMTP_CLIENT_OPTIONS_KEY = "SmtpClientOptions"; + private const string SMTP_CLIENT_OPTIONS_DEFAULT_FROM_EMAIL = "SmtpClientOptions:DefaultFromEmail"; + private const string DEFAULT_FROM_EMAIL = "noreply@blazorserver.com"; private const string DATABASE_SETTINGS_KEY = "DatabaseSettings"; private const string NPGSQL_ENABLE_LEGACY_TIMESTAMP_BEHAVIOR = "Npgsql.EnableLegacyTimestampBehavior"; private const string MSSQL_MIGRATIONS_ASSEMBLY = "CleanAspire.Migrators.MSSQL"; @@ -32,7 +36,18 @@ public static class DependencyInjection private const string POSTGRESQL_MIGRATIONS_ASSEMBLY = "CleanAspire.Migrators.PostgreSQL"; private const string USE_IN_MEMORY_DATABASE_KEY = "UseInMemoryDatabase"; private const string IN_MEMORY_DATABASE_NAME = "CleanAspireDb"; - public static IServiceCollection AddDatabase(this IServiceCollection services, + + public static IServiceCollection AddInfrastructure(this IServiceCollection services, + IConfiguration configuration) + { + services + .AddDatabase(configuration); + + + return services; + } + + private static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection(DATABASE_SETTINGS_KEY)) diff --git a/src/CleanAspire.ServiceDefaults/CleanAspire.ServiceDefaults.csproj b/src/CleanAspire.ServiceDefaults/CleanAspire.ServiceDefaults.csproj index 243848a..827959b 100644 --- a/src/CleanAspire.ServiceDefaults/CleanAspire.ServiceDefaults.csproj +++ b/src/CleanAspire.ServiceDefaults/CleanAspire.ServiceDefaults.csproj @@ -12,10 +12,10 @@ - - - - + + + +