diff --git a/DysonNetwork.Pass/Auth/AuthController.cs b/DysonNetwork.Pass/Auth/AuthController.cs index 5232165..8175077 100644 --- a/DysonNetwork.Pass/Auth/AuthController.cs +++ b/DysonNetwork.Pass/Auth/AuthController.cs @@ -15,9 +15,12 @@ public class AuthController( AccountService accounts, AuthService auth, GeoIpService geo, - ActionLogService als + ActionLogService als, + IConfiguration configuration ) : ControllerBase { + private readonly string CookieDomain = configuration["AuthToken:CookieDomain"]!; + public class ChallengeRequest { [Required] public ChallengePlatform Platform { get; set; } @@ -80,8 +83,8 @@ public class AuthController( .ThenInclude(e => e.Profile) .FirstOrDefaultAsync(e => e.Id == id); - return challenge is null - ? NotFound("Auth challenge was not found.") + return challenge is null + ? NotFound("Auth challenge was not found.") : challenge; } @@ -249,11 +252,19 @@ public class AuthController( await db.SaveChangesAsync(); var tk = auth.CreateToken(session); + Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Domain = CookieDomain, + Expires = DateTime.UtcNow.AddDays(30) + }); + return Ok(new TokenExchangeResponse { Token = tk }); - case "refresh_token": - // Since we no longer need the refresh token - // This case is blank for now, thinking to mock it if the OIDC standard requires it default: + // Since we no longer need the refresh token + // This case is blank for now, thinking to mock it if the OIDC standard requires it return BadRequest("Unsupported grant type."); } } @@ -264,4 +275,17 @@ public class AuthController( var result = await auth.ValidateCaptcha(token); return result ? Ok() : BadRequest(); } + + [HttpPost("logout")] + public IActionResult Logout() + { + Response.Cookies.Delete(AuthConstants.CookieTokenName, new CookieOptions + { + Domain = CookieDomain, + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax + }); + return Ok(); + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/CaptchaController.cs b/DysonNetwork.Pass/Auth/CaptchaController.cs new file mode 100644 index 0000000..a51c608 --- /dev/null +++ b/DysonNetwork.Pass/Auth/CaptchaController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DysonNetwork.Pass.Auth; + +[ApiController] +[Route("/api/captcha")] +public class CaptchaController(IConfiguration configuration) : ControllerBase +{ + [HttpGet] + public IActionResult GetConfiguration() + { + return Ok(new + { + provider = configuration["Captcha:Provider"], + apiKey = configuration["Captcha:ApiKey"], + }); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Client/bun.lock b/DysonNetwork.Pass/Client/bun.lock index 6d0070b..6e6c711 100644 --- a/DysonNetwork.Pass/Client/bun.lock +++ b/DysonNetwork.Pass/Client/bun.lock @@ -6,8 +6,11 @@ "dependencies": { "@fingerprintjs/fingerprintjs": "^4.6.2", "@fontsource-variable/nunito": "^5.2.6", + "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@tailwindcss/vite": "^4.1.11", + "@vueuse/core": "^13.5.0", "aspnet-prerendering": "^3.0.1", + "cfturnstile-vue3": "^2.0.0", "pinia": "^3.0.3", "tailwindcss": "^4.1.11", "vue": "^3.5.17", @@ -136,6 +139,8 @@ "@fontsource-variable/nunito": ["@fontsource-variable/nunito@5.2.6", "", {}, "sha512-dGYTQ0Hl94jjfMraYefrURHGH8fk/vL/1zYAZGofiPJVs6C0OkM8T87Te5Gwrbe6HG/XEMm5lib8AqasTN3ucw=="], + "@hcaptcha/vue3-hcaptcha": ["@hcaptcha/vue3-hcaptcha@1.3.0", "", { "dependencies": { "vue": "^3.2.19" } }, "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], @@ -270,6 +275,8 @@ "@types/node": ["@types/node@22.16.4", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-PYRhNtZdm2wH/NT2k/oAJ6/f2VD2N2Dag0lGlx2vWgMSJXGNmlce5MiTQzoWAiIJtso30mjnfQCOKVH+kAQC/g=="], + "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.37.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/type-utils": "8.37.0", "@typescript-eslint/utils": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.37.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.37.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/types": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA=="], @@ -344,6 +351,12 @@ "@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="], + "@vueuse/core": ["@vueuse/core@13.5.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.5.0", "@vueuse/shared": "13.5.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g=="], + + "@vueuse/metadata": ["@vueuse/metadata@13.5.0", "", {}, "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw=="], + + "@vueuse/shared": ["@vueuse/shared@13.5.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -380,6 +393,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], + "cfturnstile-vue3": ["cfturnstile-vue3@2.0.0", "", { "dependencies": { "vue": "^3.2.38" } }, "sha512-wamRC8ZoUAjvfOVoPAbJM14qqxc0gfjqfV6ESZh4rMs7G0yp+R4dpHNjxa7YAjdFTutaviMEZYCuK9tM4ZaGJQ=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], diff --git a/DysonNetwork.Pass/Client/package.json b/DysonNetwork.Pass/Client/package.json index f20db8b..3d7ffb9 100644 --- a/DysonNetwork.Pass/Client/package.json +++ b/DysonNetwork.Pass/Client/package.json @@ -17,8 +17,11 @@ "dependencies": { "@fingerprintjs/fingerprintjs": "^4.6.2", "@fontsource-variable/nunito": "^5.2.6", + "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@tailwindcss/vite": "^4.1.11", + "@vueuse/core": "^13.5.0", "aspnet-prerendering": "^3.0.1", + "cfturnstile-vue3": "^2.0.0", "pinia": "^3.0.3", "tailwindcss": "^4.1.11", "vue": "^3.5.17", diff --git a/DysonNetwork.Pass/Client/src/components/Captcha.vue b/DysonNetwork.Pass/Client/src/components/Captcha.vue new file mode 100644 index 0000000..4b60227 --- /dev/null +++ b/DysonNetwork.Pass/Client/src/components/Captcha.vue @@ -0,0 +1,65 @@ + + + diff --git a/DysonNetwork.Pass/Client/src/layouts/default.vue b/DysonNetwork.Pass/Client/src/layouts/default.vue index 61f47f2..6e1e625 100644 --- a/DysonNetwork.Pass/Client/src/layouts/default.vue +++ b/DysonNetwork.Pass/Client/src/layouts/default.vue @@ -1,25 +1,115 @@ diff --git a/DysonNetwork.Pass/Client/src/root.vue b/DysonNetwork.Pass/Client/src/root.vue index e827a5c..f238abb 100644 --- a/DysonNetwork.Pass/Client/src/root.vue +++ b/DysonNetwork.Pass/Client/src/root.vue @@ -2,14 +2,37 @@ import LayoutDefault from './layouts/default.vue' import { RouterView } from 'vue-router' -import { NGlobalStyle, NConfigProvider } from 'naive-ui' +import { NGlobalStyle, NConfigProvider, NMessageProvider, lightTheme, darkTheme } from 'naive-ui' +import { usePreferredDark } from '@vueuse/core' +import { useUserStore } from './stores/user' +import { onMounted } from 'vue' + +const themeOverrides = { + common: { + fontFamily: 'Nunito Variable, v-sans, ui-system, -apple-system, sans-serif', + primaryColor: '#7D80BAFF', + primaryColorHover: '#9294C5FF', + primaryColorPressed: '#575B9DFF', + primaryColorSuppl: '#6B6FC1FF', + }, +} + +const isDark = usePreferredDark() + +const userStore = useUserStore() + +onMounted(() => { + userStore.fetchUser() +}) diff --git a/DysonNetwork.Pass/Client/src/router/index.ts b/DysonNetwork.Pass/Client/src/router/index.ts index 6f32e4f..e5d8dc1 100644 --- a/DysonNetwork.Pass/Client/src/router/index.ts +++ b/DysonNetwork.Pass/Client/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/stores/user' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -6,24 +7,50 @@ const router = createRouter({ { path: '/', name: 'index', - component: () => import('../views/index.vue'), + component: () => import('../views/index.vue') }, { path: '/captcha', name: 'captcha', - component: () => import('../views/captcha.vue'), + component: () => import('../views/captcha.vue') }, { path: '/spells/:word', name: 'spells', - component: () => import('../views/spells.vue'), + component: () => import('../views/spells.vue') }, { path: '/login', name: 'login', - component: () => import('../views/login.vue'), + component: () => import('../views/login.vue') }, - ], + { + path: '/create-account', + name: 'create-account', + component: () => import('../views/create-account.vue') + }, + { + path: '/accounts/me', + name: 'me', + component: () => import('../views/accounts/me.vue'), + meta: { requiresAuth: true } + } + ] +}) + +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore() + + // Initialize user state if not already initialized + if (!userStore.user && localStorage.getItem('authToken')) { + await userStore.initialize() + } + + if (to.matched.some((record) => record.meta.requiresAuth) && !userStore.isAuthenticated) { + next({ name: 'login', query: { redirect: to.fullPath } }) + } else { + next() + } }) export default router diff --git a/DysonNetwork.Pass/Client/src/stores/services.ts b/DysonNetwork.Pass/Client/src/stores/services.ts new file mode 100644 index 0000000..e1cf94e --- /dev/null +++ b/DysonNetwork.Pass/Client/src/stores/services.ts @@ -0,0 +1,3 @@ +import { defineStore } from 'pinia' + +export const useServicesStore = defineStore('services', () => {}) diff --git a/DysonNetwork.Pass/Client/src/stores/user.ts b/DysonNetwork.Pass/Client/src/stores/user.ts new file mode 100644 index 0000000..07287c5 --- /dev/null +++ b/DysonNetwork.Pass/Client/src/stores/user.ts @@ -0,0 +1,66 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useUserStore = defineStore('user', () => { + // State + const user = ref(null) + const isLoading = ref(false) + const error = ref(null) + + // Getters + const isAuthenticated = computed(() => !!user.value) + + // Actions + async function fetchUser() { + const token = localStorage.getItem('authToken') + if (!token) { + return // No token, no need to fetch + } + + isLoading.value = true + error.value = null + try { + const response = await fetch('/api/accounts/me', { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + if (!response.ok) { + // If the token is invalid, clear it and the user state + if (response.status === 401) { + logout() + } + throw new Error('Failed to fetch user information.') + } + + user.value = await response.json() + } catch (e: any) { + error.value = e.message + user.value = null // Clear user data on error + } finally { + isLoading.value = false + } + } + + function logout() { + user.value = null + localStorage.removeItem('authToken') + // Optionally, redirect to login page + // router.push('/login') + } + + async function initialize() { + await fetchUser() + } + + return { + user, + isLoading, + error, + isAuthenticated, + fetchUser, + logout, + initialize + } +}) \ No newline at end of file diff --git a/DysonNetwork.Pass/Client/src/views/accounts/me.vue b/DysonNetwork.Pass/Client/src/views/accounts/me.vue new file mode 100644 index 0000000..11ac3ca --- /dev/null +++ b/DysonNetwork.Pass/Client/src/views/accounts/me.vue @@ -0,0 +1,55 @@ + + + diff --git a/DysonNetwork.Pass/Client/src/views/captcha.vue b/DysonNetwork.Pass/Client/src/views/captcha.vue index 7d7a815..4b6e196 100644 --- a/DysonNetwork.Pass/Client/src/views/captcha.vue +++ b/DysonNetwork.Pass/Client/src/views/captcha.vue @@ -1,38 +1,34 @@ + \ No newline at end of file diff --git a/DysonNetwork.Pass/Client/src/views/create-account.vue b/DysonNetwork.Pass/Client/src/views/create-account.vue new file mode 100644 index 0000000..0943ce2 --- /dev/null +++ b/DysonNetwork.Pass/Client/src/views/create-account.vue @@ -0,0 +1,174 @@ + + + diff --git a/DysonNetwork.Pass/Client/src/views/login.vue b/DysonNetwork.Pass/Client/src/views/login.vue index d4b2315..f4af640 100644 --- a/DysonNetwork.Pass/Client/src/views/login.vue +++ b/DysonNetwork.Pass/Client/src/views/login.vue @@ -1,7 +1,9 @@