From 8a4c4af8af392543754561deb590da9f2dfd10c6 Mon Sep 17 00:00:00 2001 From: Kayori Date: Sun, 18 Jan 2026 22:02:20 +0100 Subject: [PATCH] Added front end login and beginnen of card mangment page. Also cleaned up the main server endpoints and added docker file --- Abstractions/Entities/User.cs | 2 +- Abstractions/Services/ICardService.cs | 5 + Abstractions/Services/IUserService.cs | 6 +- Core/Client/Client.esproj | 2 +- Core/Client/bun.lock | 11 +- Core/Client/package-lock.json | 33 ++ Core/Client/package.json | 2 + Core/Client/src/assets/main.css | 1 - Core/Client/src/components/Sidebar.vue | 37 +++ Core/Client/src/data/authenticationService.ts | 120 ++++++- Core/Client/src/data/userService.ts | 26 ++ Core/Client/src/layouts/default.vue | 19 +- Core/Client/src/main.ts | 1 + Core/Client/src/pages/auth/index.vue | 77 +++-- Core/Client/src/pages/auth/register.vue | 192 ++++++++--- Core/Client/src/pages/cards/index.vue | 49 +++ Core/Client/src/pages/index.vue | 14 +- Core/Client/src/router/index.ts | 11 +- Core/Client/src/store/userStore.ts | 91 +++-- .../src/types/authenticationResponse.ts | 4 + Core/Client/src/types/card.ts | 5 + Core/Client/src/types/loginRequest.ts | 6 + Core/Client/src/types/tokenResponse.ts | 5 + Core/Client/src/volt/Menu.vue | 55 +++ Core/Client/src/volt/Message.vue | 79 +++++ Core/Client/typed-router.d.ts | 1 + Core/Client/vite.config.ts | 4 +- Core/Server/Api/CardsApi.cs | 76 +++++ Core/Server/Api/UserApi.cs | 26 ++ Core/Server/Authentication/AuthEndpoints.cs | 49 ++- Core/Server/Dockerfile | 38 +++ .../Card/AuthPassCardManegementHandler.cs | 2 +- .../Card/GetRefIdCardManagementHandler.cs | 2 +- Core/Server/Medusa.db-shm | Bin 0 -> 32768 bytes Core/Server/Medusa.db-wal | Bin 0 -> 8272 bytes ...23_SavePinAsStringInsteadOfInt.Designer.cs | 314 ++++++++++++++++++ ...60118164523_SavePinAsStringInsteadOfInt.cs | 34 ++ .../Migrations/AppDbContextModelSnapshot.cs | 217 ++++++------ Core/Server/Models/Request/RegisterRequest.cs | 10 + .../Models/Request/ValidateCardRequest.cs | 7 + Core/Server/Models/Response/CardDto.cs | 10 + .../Models/Response/ValidateCardResponse.cs | 7 + Core/Server/Program.cs | 9 +- Core/Server/Properties/launchSettings.json | 10 + Core/Server/Server.csproj | 46 +-- Core/Server/Services/CardService.cs | 20 ++ Core/Server/Services/UserService.cs | 12 +- Core/Server/appsettings.json | 7 + 48 files changed, 1461 insertions(+), 293 deletions(-) create mode 100644 Core/Client/src/components/Sidebar.vue create mode 100644 Core/Client/src/data/userService.ts create mode 100644 Core/Client/src/pages/cards/index.vue create mode 100644 Core/Client/src/types/authenticationResponse.ts create mode 100644 Core/Client/src/types/card.ts create mode 100644 Core/Client/src/types/loginRequest.ts create mode 100644 Core/Client/src/types/tokenResponse.ts create mode 100644 Core/Client/src/volt/Menu.vue create mode 100644 Core/Client/src/volt/Message.vue create mode 100644 Core/Server/Api/CardsApi.cs create mode 100644 Core/Server/Api/UserApi.cs create mode 100644 Core/Server/Dockerfile create mode 100644 Core/Server/Medusa.db-shm create mode 100644 Core/Server/Medusa.db-wal create mode 100644 Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.Designer.cs create mode 100644 Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.cs create mode 100644 Core/Server/Models/Request/RegisterRequest.cs create mode 100644 Core/Server/Models/Request/ValidateCardRequest.cs create mode 100644 Core/Server/Models/Response/CardDto.cs create mode 100644 Core/Server/Models/Response/ValidateCardResponse.cs diff --git a/Abstractions/Entities/User.cs b/Abstractions/Entities/User.cs index eff857f..d0d0fa1 100644 --- a/Abstractions/Entities/User.cs +++ b/Abstractions/Entities/User.cs @@ -6,7 +6,7 @@ namespace Abstractions.Entities public class User: IdentityUser { public int PaseliAmount { get; set; } = 0; - public int Pin { get; init; } + public string Pin { get; init; } = "0000"; public List Cards { get; init; } = null!; } diff --git a/Abstractions/Services/ICardService.cs b/Abstractions/Services/ICardService.cs index 31d51e0..d3fd639 100644 --- a/Abstractions/Services/ICardService.cs +++ b/Abstractions/Services/ICardService.cs @@ -1,4 +1,5 @@  +using System.Collections.Generic; using System.Threading.Tasks; using Abstractions.Entities; @@ -10,5 +11,9 @@ namespace Abstractions.Services string ConvertKonamiIdToUid(string konamiId); Task FindByKonamiId(string konamiId); Task FindByCardId(string cardId); + Task Exists(string konamiId); + Task ValidatePinAsync(string konamiId, string pin); + Task IsRegistered(string konamiId, string pin); + Task> GetCardsByUserIdAsync(int userId); } } diff --git a/Abstractions/Services/IUserService.cs b/Abstractions/Services/IUserService.cs index 5539100..2560428 100644 --- a/Abstractions/Services/IUserService.cs +++ b/Abstractions/Services/IUserService.cs @@ -1,6 +1,10 @@ -namespace Abstractions.Services +using Abstractions.Entities; +using System.Threading.Tasks; + +namespace Abstractions.Services { public interface IUserService { + Task GetUserByKonamiId(string konamiId); } } diff --git a/Core/Client/Client.esproj b/Core/Client/Client.esproj index 1613bf5..c702900 100644 --- a/Core/Client/Client.esproj +++ b/Core/Client/Client.esproj @@ -2,7 +2,7 @@ npm run dev .\ - Jest + Vitest false diff --git a/Core/Client/bun.lock b/Core/Client/bun.lock index ac3de86..cf17e34 100644 --- a/Core/Client/bun.lock +++ b/Core/Client/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "client", @@ -9,10 +10,12 @@ "axios": "^1.10.0", "oh-vue-icons": "^1.0.0-rc3", "pinia": "^3.0.3", + "primeicons": "^7.0.0", "primevue": "^4.3.6", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tailwindcss-primeui": "^0.6.1", + "vee-validate": "^4.15.1", "vite-plugin-vue-layouts-next": "^1.0.0", "vue": "^3.4.29", "vue-router": "4", @@ -600,6 +603,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "primeicons": ["primeicons@7.0.0", "", {}, "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw=="], + "primevue": ["primevue@4.3.6", "", { "dependencies": { "@primeuix/styled": "^0.7.0", "@primeuix/styles": "^1.2.0", "@primeuix/utils": "^0.6.0", "@primevue/core": "4.3.6", "@primevue/icons": "4.3.6" } }, "sha512-Wwg2dH6pBmOdkj9L/OnrCQf9AKPHfY5CcfnDyWeh0tNlR+XjYKGl8qvMdJOvGO9jjg6UdsX5MSaU8vDDsSG+sg=="], "property-expr": ["property-expr@2.0.6", "", {}, "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="], @@ -674,7 +679,7 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], @@ -692,6 +697,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vee-validate": ["vee-validate@4.15.1", "", { "dependencies": { "@vue/devtools-api": "^7.5.2", "type-fest": "^4.8.3" }, "peerDependencies": { "vue": "^3.4.26" } }, "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg=="], + "vite": ["vite@5.4.11", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q=="], "vite-plugin-vue-layouts-next": ["vite-plugin-vue-layouts-next@1.0.0", "", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vue": "^3.2.4", "vue-router": "^4.0.11" } }, "sha512-y3oNYcJs5lrv4F/E0EXU7OXi88CH1/b/DOk9nsFAeglxmk4Ps635DTy9R29Nl9Vj7LteBRKaxOkKVSHKOMqo7A=="], @@ -796,6 +803,8 @@ "vue-router/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + "yup/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], diff --git a/Core/Client/package-lock.json b/Core/Client/package-lock.json index 41e9624..e7e5705 100644 --- a/Core/Client/package-lock.json +++ b/Core/Client/package-lock.json @@ -13,10 +13,12 @@ "axios": "^1.10.0", "oh-vue-icons": "^1.0.0-rc3", "pinia": "^3.0.3", + "primeicons": "^7.0.0", "primevue": "^4.3.6", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tailwindcss-primeui": "^0.6.1", + "vee-validate": "^4.15.1", "vite-plugin-vue-layouts-next": "^1.0.0", "vue": "^3.4.29", "vue-router": "4", @@ -4089,6 +4091,12 @@ "node": ">= 0.8.0" } }, + "node_modules/primeicons": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", + "license": "MIT" + }, "node_modules/primevue": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/primevue/-/primevue-4.3.6.tgz", @@ -4701,6 +4709,31 @@ "dev": true, "license": "MIT" }, + "node_modules/vee-validate": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.15.1.tgz", + "integrity": "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.5.2", + "type-fest": "^4.8.3" + }, + "peerDependencies": { + "vue": "^3.4.26" + } + }, + "node_modules/vee-validate/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/vite": { "version": "5.4.11", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", diff --git a/Core/Client/package.json b/Core/Client/package.json index 550933e..4b1b0b0 100644 --- a/Core/Client/package.json +++ b/Core/Client/package.json @@ -17,10 +17,12 @@ "axios": "^1.10.0", "oh-vue-icons": "^1.0.0-rc3", "pinia": "^3.0.3", + "primeicons": "^7.0.0", "primevue": "^4.3.6", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tailwindcss-primeui": "^0.6.1", + "vee-validate": "^4.15.1", "vite-plugin-vue-layouts-next": "^1.0.0", "vue": "^3.4.29", "vue-router": "4", diff --git a/Core/Client/src/assets/main.css b/Core/Client/src/assets/main.css index 711d3f6..3610597 100644 --- a/Core/Client/src/assets/main.css +++ b/Core/Client/src/assets/main.css @@ -2,7 +2,6 @@ @import "tailwindcss"; @import "tailwindcss-primeui"; - /* Primary and Surface Palettes */ :root { --p-primary-50: #ecfdf5; diff --git a/Core/Client/src/components/Sidebar.vue b/Core/Client/src/components/Sidebar.vue new file mode 100644 index 0000000..9ebc60e --- /dev/null +++ b/Core/Client/src/components/Sidebar.vue @@ -0,0 +1,37 @@ + + + \ No newline at end of file diff --git a/Core/Client/src/data/authenticationService.ts b/Core/Client/src/data/authenticationService.ts index c0f08ab..cf70db0 100644 --- a/Core/Client/src/data/authenticationService.ts +++ b/Core/Client/src/data/authenticationService.ts @@ -1,7 +1,121 @@ +import { useUserStore } from "@/store/userStore"; +import type { AuthenticationResponse } from "@/types/authenticationResponse"; +import type { LoginRequest } from "@/types/loginRequest"; +import type { TokenResponse } from "@/types/tokenResponse"; +import { UserService } from "./userService"; export class AuthenticationService { - static async refreshAccessToken(refreshToken: string, userId: string) { - return false; + static async refreshAccessToken(refreshToken: string) { + const store = useUserStore(); + const response = await fetch("/api/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refreshToken: refreshToken + }), + }); + + if (!response.ok) { + return false; + } + const tokenResponse = (await response.json()) as TokenResponse; + + sessionStorage.setItem("accessToken", tokenResponse.accessToken); + localStorage.setItem("refreshToken", tokenResponse.refreshToken); + + store.setAccessToken(tokenResponse.accessToken); + store.setRefreshToken(tokenResponse.refreshToken); + store.setLoggedIn(true); + + if (await UserService.GetUserClaims()) { + return true; } -} \ No newline at end of file + return false; + } + + static async login( + loginRequest: LoginRequest, + ): Promise { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(loginRequest), + }); + + if (!response.ok) { + const json = await response.json(); + if (json.toString().includes( + "Invalid email or password", + ) + ) { + return { + twoFactorRequired: false, + incorrectCredentials: true, + }; + } + } + + const tokenResponse = (await response.json()) as TokenResponse; + + sessionStorage.setItem("accessToken", tokenResponse.accessToken); + localStorage.setItem("refreshToken", tokenResponse.refreshToken); + + const store = useUserStore(); + + store.setAccessToken(tokenResponse.accessToken); + store.setRefreshToken(tokenResponse.refreshToken); + store.setLoggedIn(true); + + if (await UserService.GetUserClaims()) { + return { + twoFactorRequired: false, + incorrectCredentials: false, + }; + } + + return { + twoFactorRequired: true, + incorrectCredentials: true, + }; + } + + static async registerUser( + konamiId: string, + pinCode: string, + username: string, + email: string, + password: string, + ) { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + konamiId, + pin: pinCode, + username, + email, + password, + }), + }); + + if (!response.ok) { + return false; + } + + return !( + await this.login({ + email: email, + password: password, + twoFactorCode: undefined, + twoFactorRecoveryCode: undefined, + }) + ).incorrectCredentials; + } +} diff --git a/Core/Client/src/data/userService.ts b/Core/Client/src/data/userService.ts new file mode 100644 index 0000000..d68246d --- /dev/null +++ b/Core/Client/src/data/userService.ts @@ -0,0 +1,26 @@ +import { useUserStore } from "@/store/userStore"; +import type { UserClaim } from "@/types/userClaim"; + +export class UserService { + static async GetUserClaims(): Promise { + const response = await fetch("/api/user/claims", { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${sessionStorage.getItem("accessToken")}`, + }, + }); + + if (!response.ok) { + return false; + } + const userClaims = (await response.json()) as UserClaim[]; + + const store = useUserStore(); + store.setUser(userClaims); + store.setName( + userClaims.find((claim) => claim.type === "name")?.value || "", + ); + return true; + } +} \ No newline at end of file diff --git a/Core/Client/src/layouts/default.vue b/Core/Client/src/layouts/default.vue index 1ecb5a5..c7f6f3f 100644 --- a/Core/Client/src/layouts/default.vue +++ b/Core/Client/src/layouts/default.vue @@ -1,7 +1,16 @@ + + \ No newline at end of file diff --git a/Core/Client/src/main.ts b/Core/Client/src/main.ts index 520ac54..81d1322 100644 --- a/Core/Client/src/main.ts +++ b/Core/Client/src/main.ts @@ -1,4 +1,5 @@ import './assets/main.css' +import 'primeicons/primeicons.css' import { createApp } from 'vue' import PrimeVue from 'primevue/config'; diff --git a/Core/Client/src/pages/auth/index.vue b/Core/Client/src/pages/auth/index.vue index f72aa1f..de6352b 100644 --- a/Core/Client/src/pages/auth/index.vue +++ b/Core/Client/src/pages/auth/index.vue @@ -1,18 +1,45 @@ @@ -23,26 +50,32 @@ meta: \ No newline at end of file diff --git a/Core/Client/src/pages/auth/register.vue b/Core/Client/src/pages/auth/register.vue index adab785..e01c6ad 100644 --- a/Core/Client/src/pages/auth/register.vue +++ b/Core/Client/src/pages/auth/register.vue @@ -1,65 +1,155 @@ - - -meta: - layout: auth - + + meta: + layout: auth + - \ No newline at end of file diff --git a/Core/Client/src/pages/cards/index.vue b/Core/Client/src/pages/cards/index.vue new file mode 100644 index 0000000..26b2406 --- /dev/null +++ b/Core/Client/src/pages/cards/index.vue @@ -0,0 +1,49 @@ + + + \ No newline at end of file diff --git a/Core/Client/src/pages/index.vue b/Core/Client/src/pages/index.vue index 96c0baf..555a03b 100644 --- a/Core/Client/src/pages/index.vue +++ b/Core/Client/src/pages/index.vue @@ -1,11 +1,17 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Core/Client/src/router/index.ts b/Core/Client/src/router/index.ts index 40af593..54cd0a1 100644 --- a/Core/Client/src/router/index.ts +++ b/Core/Client/src/router/index.ts @@ -15,17 +15,11 @@ router.beforeEach(async (to, from, next) => { let store = useUserStore(); let user = store.user; - //dont let people go back to the splash page - if (to.path === "/" && from.path !== "/") { - next(from); - return; - } //if the user is not logged in, redirect them to the welcome page const requiresAuth = to.meta.requiresAuth !== undefined && to.meta.requiresAuth === false; if (!requiresAuth && user === undefined) { const refreshToken = localStorage.getItem("refreshToken") as string; - const userId = localStorage.getItem("userId") as string; if (!refreshToken) { next("/auth"); return; @@ -33,12 +27,11 @@ router.beforeEach(async (to, from, next) => { //i think store gets reset when you switch between apps. So we need to rehydrate the store const success = await AuthenticationService.refreshAccessToken( - refreshToken, - userId + refreshToken ); if (!success) { - next("/"); + next("/auth"); return; } } diff --git a/Core/Client/src/store/userStore.ts b/Core/Client/src/store/userStore.ts index ea79d69..5827f2b 100644 --- a/Core/Client/src/store/userStore.ts +++ b/Core/Client/src/store/userStore.ts @@ -1,53 +1,46 @@ import { defineStore } from "pinia"; import type { UserClaim } from "@/types/userClaim"; -export const useUserStore = defineStore('user', { - state: () => { - return { - user: undefined as UserClaim[] | undefined, - refreshToken: undefined as string | undefined, - accessToken: undefined as string | undefined, - name: undefined as string | undefined, - profilePicture: undefined as string | undefined, - notificationCount: 0 - }; +export const useUserStore = defineStore("user", { + state: () => { + return { + user: undefined as UserClaim[] | undefined, + refreshToken: undefined as string | undefined, + accessToken: undefined as string | undefined, + isLoggedIn: false, + name: undefined as string | undefined, + }; + }, + actions: { + setUser(user: UserClaim[]) { + this.user = user; }, - actions: { - setUser(user: UserClaim[]) { - this.user = user; - }, - setRefreshToken(refreshToken: string) { - this.refreshToken = refreshToken; - }, - setAccessToken(accessToken: string) { - this.accessToken = accessToken; - }, - setName(name: string) { - this.name = name; - }, - setProfilePicture(profilePicture: string) { - this.profilePicture = profilePicture; - }, - setNotificationCount(count: number) { - this.notificationCount = count; - }, - unsetUser() { - this.user = undefined; - }, - unsetRefreshToken() { - this.refreshToken = undefined; - }, - unsetAccessToken() { - this.accessToken = undefined; - }, - unsetName() { - this.name = undefined; - }, - unsetProfilePicture() { - this.profilePicture = undefined; - }, - resetNotificationCount() { - this.notificationCount = 0; - } - } -}) \ No newline at end of file + setRefreshToken(refreshToken: string) { + this.refreshToken = refreshToken; + }, + setAccessToken(accessToken: string) { + this.accessToken = accessToken; + }, + setLoggedIn(isLoggedIn: boolean) { + this.isLoggedIn = isLoggedIn; + }, + setName(name: string) { + this.name = name; + }, + unsetUser() { + this.user = undefined; + }, + unsetRefreshToken() { + this.refreshToken = undefined; + }, + unsetAccessToken() { + this.accessToken = undefined; + }, + unsetLoggedIn() { + this.isLoggedIn = false; + }, + unsetName() { + this.name = undefined; + }, + }, +}); diff --git a/Core/Client/src/types/authenticationResponse.ts b/Core/Client/src/types/authenticationResponse.ts new file mode 100644 index 0000000..be07842 --- /dev/null +++ b/Core/Client/src/types/authenticationResponse.ts @@ -0,0 +1,4 @@ +export type AuthenticationResponse = { + incorrectCredentials: boolean; + twoFactorRequired: boolean; +}; diff --git a/Core/Client/src/types/card.ts b/Core/Client/src/types/card.ts new file mode 100644 index 0000000..5974c6c --- /dev/null +++ b/Core/Client/src/types/card.ts @@ -0,0 +1,5 @@ +export type Card = { + konamiId: string; + rawId: string; + userId: number; +} \ No newline at end of file diff --git a/Core/Client/src/types/loginRequest.ts b/Core/Client/src/types/loginRequest.ts new file mode 100644 index 0000000..4b7bec5 --- /dev/null +++ b/Core/Client/src/types/loginRequest.ts @@ -0,0 +1,6 @@ +export type LoginRequest = { + email: string; + password: string; + twoFactorCode: string | null | undefined; + twoFactorRecoveryCode: string | null | undefined; +}; diff --git a/Core/Client/src/types/tokenResponse.ts b/Core/Client/src/types/tokenResponse.ts new file mode 100644 index 0000000..bfd7524 --- /dev/null +++ b/Core/Client/src/types/tokenResponse.ts @@ -0,0 +1,5 @@ +export type TokenResponse = { + accessToken: string; + refreshToken: string; + expiresIn: number; +}; \ No newline at end of file diff --git a/Core/Client/src/volt/Menu.vue b/Core/Client/src/volt/Menu.vue new file mode 100644 index 0000000..eea86f2 --- /dev/null +++ b/Core/Client/src/volt/Menu.vue @@ -0,0 +1,55 @@ + + + diff --git a/Core/Client/src/volt/Message.vue b/Core/Client/src/volt/Message.vue new file mode 100644 index 0000000..b3ae64b --- /dev/null +++ b/Core/Client/src/volt/Message.vue @@ -0,0 +1,79 @@ + + + diff --git a/Core/Client/typed-router.d.ts b/Core/Client/typed-router.d.ts index f00186c..896900e 100644 --- a/Core/Client/typed-router.d.ts +++ b/Core/Client/typed-router.d.ts @@ -21,5 +21,6 @@ declare module 'vue-router/auto-routes' { '/': RouteRecordInfo<'/', '/', Record, Record>, '/auth/': RouteRecordInfo<'/auth/', '/auth', Record, Record>, '/auth/register': RouteRecordInfo<'/auth/register', '/auth/register', Record, Record>, + '/cards/': RouteRecordInfo<'/cards/', '/cards', Record, Record>, } } diff --git a/Core/Client/vite.config.ts b/Core/Client/vite.config.ts index e6d52bc..06ccddf 100644 --- a/Core/Client/vite.config.ts +++ b/Core/Client/vite.config.ts @@ -47,10 +47,10 @@ export default defineConfig({ }, server: { proxy: { - '^/weatherforecast': { + '^/api': { target, secure: false - } + }, }, port: 5173, https: { diff --git a/Core/Server/Api/CardsApi.cs b/Core/Server/Api/CardsApi.cs new file mode 100644 index 0000000..6384e69 --- /dev/null +++ b/Core/Server/Api/CardsApi.cs @@ -0,0 +1,76 @@ +using Abstractions.Services; +using Facet.Extensions; +using Microsoft.AspNetCore.Mvc; +using Server.Models.Request; +using Server.Models.Response; + +namespace Server.Api; + +public static class CardsApi +{ + public static RouteGroupBuilder MapCardsApiEndpoints(this RouteGroupBuilder group) + { + var cardsGroup = group.MapGroup("/cards").WithTags("Cards"); + cardsGroup.MapPost("/validate", ValidateCard).Produces(200); + cardsGroup.MapGet("/my", GetUserCards).Produces(200).RequireAuthorization(); + return group; + } + + internal static async Task ValidateCard(ValidateCardRequest request, [FromServices] ICardService cardService) + { + var isRegistered = await cardService.IsRegistered(request.CardId, request.Pin); + if (isRegistered) + { + return Results.Ok(new ValidateCardResponse + { + Message = "Card already registered.", + Success = false + }); + } + + var exists = await cardService.Exists(request.CardId); + + if (!exists) + { + return Results.Ok(new ValidateCardResponse + { + Message = "Card does not exist.", + Success = false + }); + } + + var validPin = await cardService.ValidatePinAsync(request.CardId, request.Pin); + + if (validPin) + { + return Results.Ok(new ValidateCardResponse + { + Message = "Card is valid.", + Success = true + }); + } + + return Results.Ok(new ValidateCardResponse + { + Message = "Card does not exist.", + Success = false + }); + } + + internal static async Task GetUserCards(HttpContext httpContext, [FromServices] ICardService cardService) + { + var user = httpContext.User; + if (!(httpContext.User.Identity?.IsAuthenticated ?? false)) + { + return Results.Unauthorized(); + } + var userId = user.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Results.Unauthorized(); + } + var cards = await cardService.GetCardsByUserIdAsync(int.Parse(userId)); + + return Results.Ok(cards.SelectFacets()); + } +} diff --git a/Core/Server/Api/UserApi.cs b/Core/Server/Api/UserApi.cs new file mode 100644 index 0000000..760dcc1 --- /dev/null +++ b/Core/Server/Api/UserApi.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; + +namespace Server.Api; + +public static class UserApi +{ + public static RouteGroupBuilder MapUserApiEndpoints(this RouteGroupBuilder group) + { + var userGroup = group.MapGroup("/user").WithTags("User"); + + userGroup.MapGet("/claims", GetClaims).Produces(200).RequireAuthorization(); + return group; + } + + internal static IResult GetClaims(HttpContext httpContext) + { + var user = httpContext.User; + if(!(httpContext.User.Identity?.IsAuthenticated ?? false)) + { + return Results.Unauthorized(); + } + + var claims = user.Claims.Select(c => new Claim(c.Type, c.Value)).ToArray(); + return Results.Ok(claims); + } +} diff --git a/Core/Server/Authentication/AuthEndpoints.cs b/Core/Server/Authentication/AuthEndpoints.cs index ca02d9e..813546d 100644 --- a/Core/Server/Authentication/AuthEndpoints.cs +++ b/Core/Server/Authentication/AuthEndpoints.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using Abstractions.Entities; +using Abstractions.Services; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; @@ -31,13 +32,16 @@ public static class AuthEndpoints // We'll figure out a unique endpoint name based on the final route pattern during endpoint generation. string? confirmEmailEndpointName = null; - var routeGroup = endpoints.MapGroup(""); + var routeGroup = endpoints.MapGroup("/auth"); routeGroup.MapPost("/register", async Task> - ([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) => + ([FromBody] Models.Request.RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); - + var signinManager = sp.GetRequiredService>(); + var userService = sp.GetRequiredService(); + var cardService = sp.GetRequiredService(); + if (!userManager.SupportsUserEmail) { throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with email support."); @@ -51,17 +55,34 @@ public static class AuthEndpoints { return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email))); } - - var user = new User(); - await userStore.SetUserNameAsync(user, email, CancellationToken.None); + + var validCard = await cardService.ValidatePinAsync(registration.KonamiId, registration.Pin); + + if (!validCard) + { + return CreateValidationProblem("InvalidCard", + "The provided Card is invalid"); + } + + var user = await userService.GetUserByKonamiId(registration.KonamiId); + + if (user == null) + { + return CreateValidationProblem("InvalidKonamiId", + "The provided Card is not found"); + } + + await userStore.SetUserNameAsync(user, registration.Username, CancellationToken.None); await emailStore.SetEmailAsync(user, email, CancellationToken.None); - var result = await userManager.CreateAsync(user, registration.Password); + var result = await userManager.UpdateAsync(user); if (!result.Succeeded) { return CreateValidationProblem(result); } - + + result = await userManager.AddPasswordAsync(user, registration.Password); + await SendConfirmationEmailAsync(user, userManager, context, email); return TypedResults.Ok(); }); @@ -75,8 +96,16 @@ public static class AuthEndpoints var isPersistent = (useCookies == true) && (useSessionCookies != true); signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; - var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true); - + var user = await signInManager.UserManager.FindByEmailAsync(login.Email); + user ??= await signInManager.UserManager.FindByNameAsync(login.Email); + + if (user == null) + { + return TypedResults.Problem("Invalid login attempt.", statusCode: StatusCodes.Status401Unauthorized); + } + + var result = await signInManager.PasswordSignInAsync(user!.UserName!, login.Password, isPersistent, lockoutOnFailure: true); + if (result.RequiresTwoFactor) { if (!string.IsNullOrEmpty(login.TwoFactorCode)) diff --git a/Core/Server/Dockerfile b/Core/Server/Dockerfile new file mode 100644 index 0000000..9656fe4 --- /dev/null +++ b/Core/Server/Dockerfile @@ -0,0 +1,38 @@ +# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +# This stage is used when running from VS in fast mode (Default for Debug configuration) +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + + +# This stage is used to build the service project +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS with-node +RUN apt-get update +RUN apt-get install curl +RUN curl -sL https://deb.nodesource.com/setup_20.x | bash +RUN apt-get -y install nodejs + + +FROM with-node AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Core/Server/Server.csproj", "Core/Server/"] +COPY ["Core/Client/Client.esproj", "Core/Client/"] +RUN dotnet restore "./Core/Server/Server.csproj" +COPY . . +WORKDIR "/src/Core/Server" +RUN dotnet build "./Server.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# This stage is used to publish the service project to be copied to the final stage +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Server.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Server.dll"] diff --git a/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs b/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs index 69dc7f3..5afa11e 100644 --- a/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs +++ b/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs @@ -25,7 +25,7 @@ public class AuthPassCardManegementHandler(ICardService cardService) : Handlerz~%;V1X~Ai2T=qYTd^015bjz#+v^>`6*N!eu(g!?et0}c z!h?M2Q{C$n8I{T@jiXOWUFNH0>Aqc`Zo5r=SX}dQTm5*xjCv>LKELu=?py3f z2>}8G2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C7h9HprxH5!ic1HpP2oNAZfB*pk1PBlyK!5-N x0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAVA>H1YXY8D3|~M literal 0 HcmV?d00001 diff --git a/Core/Server/Medusa.db-wal b/Core/Server/Medusa.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..f0d037abf766372f0e0035ccb48998b6d8b32aad GIT binary patch literal 8272 zcmXr7XKP~6eI&uaAiw|u%QX7-H7_~WIk{AO=T+_H4}n5VKr9UtXAg;eQ5VQJi5Doy z$bX9gh(^I^2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQN>u0To7O27e<1 zBQsqCLtR4)1tSA16EiC#Gd&}7OH(sL6Kw+nD+2?dw@}6ft~W;R%6sRz7c?#a8cG-) x7Z`F8KkDGo5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2Eh5&Kn0svb7Es_8L literal 0 HcmV?d00001 diff --git a/Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.Designer.cs b/Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.Designer.cs new file mode 100644 index 0000000..51d62f2 --- /dev/null +++ b/Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.Designer.cs @@ -0,0 +1,314 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Server; + +#nullable disable + +namespace Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260118164523_SavePinAsStringInsteadOfInt")] + partial class SavePinAsStringInsteadOfInt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("Abstractions.Entities.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("KonamiId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("Abstractions.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PaseliAmount") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Pin") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Abstractions.Entities.Card", b => + { + b.HasOne("Abstractions.Entities.User", "User") + .WithMany("Cards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Abstractions.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Abstractions.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Abstractions.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Abstractions.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Abstractions.Entities.User", b => + { + b.Navigation("Cards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.cs b/Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.cs new file mode 100644 index 0000000..3e6176d --- /dev/null +++ b/Core/Server/Migrations/20260118164523_SavePinAsStringInsteadOfInt.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Server.Migrations +{ + /// + public partial class SavePinAsStringInsteadOfInt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Pin", + table: "AspNetUsers", + type: "TEXT", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Pin", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT"); + } + } +} diff --git a/Core/Server/Migrations/AppDbContextModelSnapshot.cs b/Core/Server/Migrations/AppDbContextModelSnapshot.cs index 80a0364..d44ef91 100644 --- a/Core/Server/Migrations/AppDbContextModelSnapshot.cs +++ b/Core/Server/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,103 @@ namespace Server.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("Abstractions.Entities.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("KonamiId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("Abstractions.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PaseliAmount") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Pin") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { @@ -143,99 +239,15 @@ namespace Server.Migrations b.ToTable("AspNetUserTokens", (string)null); }); - modelBuilder.Entity("Server.Entities.Card", b => + modelBuilder.Entity("Abstractions.Entities.Card", b => { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + b.HasOne("Abstractions.Entities.User", "User") + .WithMany("Cards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.Property("KonamiId") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("RawId") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Cards", (string)null); - }); - - modelBuilder.Entity("Server.Entities.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnd") - .HasColumnType("TEXT"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PaseliAmount") - .HasColumnType("INTEGER"); - - b.Property("PasswordHash") - .HasColumnType("TEXT"); - - b.Property("PhoneNumber") - .HasColumnType("TEXT"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); - - b.Property("Pin") - .HasColumnType("INTEGER"); - - b.Property("SecurityStamp") - .HasColumnType("TEXT"); - - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); + b.Navigation("User"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => @@ -249,7 +261,7 @@ namespace Server.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("Server.Entities.User", null) + b.HasOne("Abstractions.Entities.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -258,7 +270,7 @@ namespace Server.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("Server.Entities.User", null) + b.HasOne("Abstractions.Entities.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -273,7 +285,7 @@ namespace Server.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Server.Entities.User", null) + b.HasOne("Abstractions.Entities.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -282,25 +294,14 @@ namespace Server.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("Server.Entities.User", null) + b.HasOne("Abstractions.Entities.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Server.Entities.Card", b => - { - b.HasOne("Server.Entities.User", "User") - .WithMany("Cards") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Server.Entities.User", b => + modelBuilder.Entity("Abstractions.Entities.User", b => { b.Navigation("Cards"); }); diff --git a/Core/Server/Models/Request/RegisterRequest.cs b/Core/Server/Models/Request/RegisterRequest.cs new file mode 100644 index 0000000..c35908a --- /dev/null +++ b/Core/Server/Models/Request/RegisterRequest.cs @@ -0,0 +1,10 @@ +namespace Server.Models.Request; + +public class RegisterRequest +{ + public required string KonamiId { get; set; } + public required string Pin { get; set; } + public required string Username { get; set; } + public required string Email { get; set; } + public required string Password { get; set; } +} diff --git a/Core/Server/Models/Request/ValidateCardRequest.cs b/Core/Server/Models/Request/ValidateCardRequest.cs new file mode 100644 index 0000000..afb0157 --- /dev/null +++ b/Core/Server/Models/Request/ValidateCardRequest.cs @@ -0,0 +1,7 @@ +namespace Server.Models.Request; + +public class ValidateCardRequest +{ + public required string CardId { get; set; } + public required string Pin { get; set; } +} diff --git a/Core/Server/Models/Response/CardDto.cs b/Core/Server/Models/Response/CardDto.cs new file mode 100644 index 0000000..b4a272c --- /dev/null +++ b/Core/Server/Models/Response/CardDto.cs @@ -0,0 +1,10 @@ +using Abstractions.Entities; +using Facet; +using System; + +namespace Server.Models.Response; + +[Facet(typeof(Card), exclude: nameof(Card.User))] +public partial class CardDto +{ +} diff --git a/Core/Server/Models/Response/ValidateCardResponse.cs b/Core/Server/Models/Response/ValidateCardResponse.cs new file mode 100644 index 0000000..64c4146 --- /dev/null +++ b/Core/Server/Models/Response/ValidateCardResponse.cs @@ -0,0 +1,7 @@ +namespace Server.Models.Response; + +public class ValidateCardResponse +{ + public required string Message { get; set; } + public bool Success { get; set; } = false; + } diff --git a/Core/Server/Program.cs b/Core/Server/Program.cs index e0bb4b3..40827b9 100644 --- a/Core/Server/Program.cs +++ b/Core/Server/Program.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Server; +using Server.Api; using Server.Authentication; using Server.Extensions; using Server.Middlewares; @@ -56,12 +57,13 @@ builder.Services.AddDbContext(options => options.UseSqlite("Data S builder.Services.AddIdentityCore(config => { config.Password.RequiredLength = 8; - config.SignIn.RequireConfirmedEmail = true; + config.SignIn.RequireConfirmedEmail = false; config.Lockout.AllowedForNewUsers = true; }).AddEntityFrameworkStores(); builder.Services.AddTransient(); builder.Services.AddSingleton(pluginService); +builder.Services.AddTransient(); builder.Services.AddSingleton(); var app = builder.Build(); @@ -96,7 +98,10 @@ foreach (var plugin in plugins) await plugin.OnAppInitialize(app); } -app.MapIdentityApi(); +var apiGroup = app.MapGroup("/api").WithTags("API"); +apiGroup.MapCardsApiEndpoints(); +apiGroup.MapUserApiEndpoints(); +apiGroup.MapIdentityApi(); var eamuseGroup = app.MapGroup("eamuse"); diff --git a/Core/Server/Properties/launchSettings.json b/Core/Server/Properties/launchSettings.json index d8dacb7..ba3ef89 100644 --- a/Core/Server/Properties/launchSettings.json +++ b/Core/Server/Properties/launchSettings.json @@ -39,6 +39,16 @@ "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" } + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true } } } diff --git a/Core/Server/Server.csproj b/Core/Server/Server.csproj index a4ae556..c7cb31f 100644 --- a/Core/Server/Server.csproj +++ b/Core/Server/Server.csproj @@ -1,14 +1,23 @@ - + - net9.0 + net10.0 enable enable - ..\Client + 43f0ff47-d090-4bfc-a428-9a16f4291fa3 + Linux + ..\Client npm run dev https://localhost:5173 + + + + + + + @@ -17,26 +26,29 @@ - - - - - + + + + + + + - 9.0.0 + 10.*-* - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + @@ -48,10 +60,6 @@ - - - - diff --git a/Core/Server/Services/CardService.cs b/Core/Server/Services/CardService.cs index 939e1e2..65a32b9 100644 --- a/Core/Server/Services/CardService.cs +++ b/Core/Server/Services/CardService.cs @@ -203,4 +203,24 @@ public class CardService : ICardService { return _context.Cards.Include(x => x.User).FirstOrDefaultAsync(c => c.RawId == cardId); } + + public Task Exists(string konamiId) + { + return _context.Cards.AnyAsync(c => c.KonamiId == konamiId); + } + + public Task ValidatePinAsync(string konamiId, string pin) + { + return _context.Cards.AnyAsync(c => c.KonamiId == konamiId && c.User.Pin == pin); + } + + public Task IsRegistered(string konamiId, string pin) + { + return _context.Cards.AnyAsync(c => c.KonamiId == konamiId && c.User.Pin == pin && c.User.PasswordHash != null); + } + + public Task> GetCardsByUserIdAsync(int userId) + { + return _context.Cards.Where(c => c.UserId == userId).ToListAsync(); + } } diff --git a/Core/Server/Services/UserService.cs b/Core/Server/Services/UserService.cs index e0b20cd..e4cf4a6 100644 --- a/Core/Server/Services/UserService.cs +++ b/Core/Server/Services/UserService.cs @@ -1,8 +1,16 @@ -using Abstractions.Services; +using Abstractions.Entities; +using Abstractions.Services; +using Microsoft.EntityFrameworkCore; namespace Server.Services { - public class UserService: IUserService + public class UserService(AppDbContext context) : IUserService { + public Task GetUserByKonamiId(string konamiId) + { + return context.Users + .Include(u => u.Cards) + .FirstOrDefaultAsync(u => u.Cards.Any(c => c.KonamiId == konamiId)); + } } } diff --git a/Core/Server/appsettings.json b/Core/Server/appsettings.json index 10f68b8..052a595 100644 --- a/Core/Server/appsettings.json +++ b/Core/Server/appsettings.json @@ -1,4 +1,11 @@ { + "Kestrel": { + "Endpoints": { + "MyHttpEndpoint": { + "Url": "http://0.0.0.0:5120" + } + } + }, "Logging": { "LogLevel": { "Default": "Information",