Procházet zdrojové kódy

Add login authentication

flyzto před 1 měsícem
rodič
revize
15d5295859

+ 93 - 0
server/libs/auth.js

@@ -0,0 +1,93 @@
+import crypto from 'node:crypto';
+
+const COOKIE_NAME = 'ppai_session';
+const DEFAULT_MAX_AGE = 12;
+
+const getConfig = () => ({
+  username: process.env.PPAI_AUTH_USER || 'admin',
+  password: process.env.PPAI_AUTH_PASSWORD || 'admin123',
+  secret: process.env.PPAI_AUTH_SECRET || 'ppai-dev-secret',
+  maxAge: Number(process.env.PPAI_AUTH_MAX_AGE || DEFAULT_MAX_AGE) * 60 * 60 * 1000,
+});
+
+const base64UrlEncode = (value) => Buffer.from(value).toString('base64url');
+const base64UrlDecode = (value) => Buffer.from(value, 'base64url').toString();
+
+const sign = (payload, secret) => {
+  return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
+};
+
+const safeEqual = (a = '', b = '') => {
+  const aBuffer = Buffer.from(a);
+  const bBuffer = Buffer.from(b);
+
+  if (aBuffer.length !== bBuffer.length) {
+    return false;
+  }
+
+  return crypto.timingSafeEqual(aBuffer, bBuffer);
+};
+
+export const cookieOptions = () => {
+  const { maxAge } = getConfig();
+
+  return {
+    httpOnly: true,
+    sameSite: 'lax',
+    secure: process.env.NODE_ENV === 'production',
+    maxAge,
+    path: '/',
+  };
+};
+
+export const clearCookieOptions = () => ({
+  ...cookieOptions(),
+  maxAge: 0,
+});
+
+export const createSession = (username) => {
+  const { secret, maxAge } = getConfig();
+  const payload = base64UrlEncode(JSON.stringify({
+    username,
+    exp: Date.now() + maxAge,
+  }));
+  const signature = sign(payload, secret);
+
+  return `${payload}.${signature}`;
+};
+
+export const verifySession = (token) => {
+  if (!token || typeof token !== 'string') {
+    return null;
+  }
+
+  const [payload, signature] = token.split('.');
+  if (!payload || !signature) {
+    return null;
+  }
+
+  const { secret } = getConfig();
+  const expectedSignature = sign(payload, secret);
+  if (!safeEqual(signature, expectedSignature)) {
+    return null;
+  }
+
+  try {
+    const session = JSON.parse(base64UrlDecode(payload));
+    if (!session?.username || !session?.exp || Date.now() > session.exp) {
+      return null;
+    }
+    return { username: session.username };
+  }
+  catch {
+    return null;
+  }
+};
+
+export const validateCredentials = (username, password) => {
+  const config = getConfig();
+
+  return safeEqual(username, config.username) && safeEqual(password, config.password);
+};
+
+export const authCookieName = COOKIE_NAME;

+ 7 - 4
server/main.js

@@ -3,10 +3,12 @@ import expressWs from 'express-ws';
 import dotenv from 'dotenv';
 import cookieParser from 'cookie-parser';
 import Logs from './libs/logs.js';
+import authRoutes from './routes/auth.js';
 import gamesRoutes from './routes/games.js';
 import localesRoutes from './routes/locales.js';
 import platformsRoutes from './routes/platforms.js';
 import partnerGateRoutes from './routes/partnerGate.js';
+import requireAuth from './middleware/requireAuth.js';
 
 const app = express();
 const wsInstance = expressWs(app);
@@ -82,11 +84,12 @@ app.use((req, res, next) => {
   next();
 });
 
-app.use('/api/games', gamesRoutes);
-app.use('/api/locales', localesRoutes);
-app.use('/api/platforms', platformsRoutes);
+app.use('/api/auth', authRoutes);
+app.use('/api/games', requireAuth, gamesRoutes);
+app.use('/api/locales', requireAuth, localesRoutes);
+app.use('/api/platforms', requireAuth, platformsRoutes);
 app.use('/api/partner', partnerGateRoutes);
 
 // 启动服务
 const PORT = process.env.PORT || 9020;
-app.listen(PORT, () => Logs.out(`Server running on port ${PORT}`));
+app.listen(PORT, () => Logs.out(`Server running on port ${PORT}`));

+ 14 - 0
server/middleware/requireAuth.js

@@ -0,0 +1,14 @@
+import { authCookieName, verifySession } from '../libs/auth.js';
+
+const requireAuth = (req, res, next) => {
+  const session = verifySession(req.cookies?.[authCookieName]);
+
+  if (!session) {
+    return res.unauthorized('请先登录');
+  }
+
+  req.user = session;
+  next();
+};
+
+export default requireAuth;

+ 40 - 0
server/routes/auth.js

@@ -0,0 +1,40 @@
+import express from 'express';
+import {
+  authCookieName,
+  clearCookieOptions,
+  cookieOptions,
+  createSession,
+  validateCredentials,
+  verifySession,
+} from '../libs/auth.js';
+
+const router = express.Router();
+
+router.post('/login', (req, res) => {
+  const { username = '', password = '' } = req.body ?? {};
+
+  if (!validateCredentials(String(username), String(password))) {
+    return res.unauthorized('用户名或密码错误');
+  }
+
+  const token = createSession(username);
+  res.cookie(authCookieName, token, cookieOptions());
+  return res.sendSuccess({ username });
+});
+
+router.post('/logout', (req, res) => {
+  res.clearCookie(authCookieName, clearCookieOptions());
+  return res.sendSuccess();
+});
+
+router.get('/me', (req, res) => {
+  const session = verifySession(req.cookies?.[authCookieName]);
+
+  if (!session) {
+    return res.unauthorized('请先登录');
+  }
+
+  return res.sendSuccess({ username: session.username });
+});
+
+export default router;

+ 22 - 0
web/src/libs/api.js

@@ -0,0 +1,22 @@
+import axios from 'axios';
+import router from '@/router';
+
+const api = axios.create({
+  withCredentials: true,
+});
+
+api.interceptors.response.use(
+  response => response,
+  error => {
+    if (error.response?.status === 401 && router.currentRoute.value.name !== 'login') {
+      router.replace({
+        name: 'login',
+        query: { redirect: router.currentRoute.value.fullPath },
+      });
+    }
+
+    return Promise.reject(error);
+  },
+);
+
+export default api;

+ 2 - 1
web/src/main.js

@@ -1,5 +1,5 @@
 import { createApp } from 'vue';
-import { Menu, PageHeader, Button, Input, List, Table } from 'ant-design-vue';
+import { Form, Menu, PageHeader, Button, Input, List, Table } from 'ant-design-vue';
 import router from './router';
 import main from '@/main.vue';
 
@@ -7,6 +7,7 @@ import 'ant-design-vue/dist/reset.css';
 
 const app = createApp(main);
 app.use(Menu);
+app.use(Form);
 app.use(PageHeader);
 app.use(Button);
 app.use(Input);

+ 72 - 27
web/src/main.vue

@@ -1,7 +1,9 @@
 <script setup>
 import { ref, watch } from 'vue';
 import { RouterView, useRoute, useRouter } from 'vue-router';
-import { AccountBookOutlined, TeamOutlined, TrophyOutlined, BookOutlined } from '@ant-design/icons-vue';
+import { AccountBookOutlined, BookOutlined, LogoutOutlined, TeamOutlined, TrophyOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+import { authState, logout } from '@/stores/auth';
 
 // 获取当前路由和路由实例
 const route = useRoute();
@@ -19,35 +21,57 @@ const handleMenuClick = (e) => {
   router.push({ name: key });
 };
 
+const handleLogout = async () => {
+  try {
+    await logout();
+    router.replace({ name: 'login' });
+  }
+  catch (err) {
+    message.error(err.response?.data?.message ?? err.message);
+  }
+};
+
 </script>
 
 <template>
-  <a-menu v-model:selectedKeys="current" mode="horizontal" @click="handleMenuClick">
-    <a-menu-item key="home">
-      <template #icon>
-        <account-book-outlined />
-      </template>
-      策略
-    </a-menu-item>
-    <a-menu-item key="leagues">
-      <template #icon>
-        <team-outlined />
-      </template>
-      联赛
-    </a-menu-item>
-    <a-menu-item key="games">
-      <template #icon>
-        <trophy-outlined />
-      </template>
-      比赛
-    </a-menu-item>
-    <a-menu-item key="locales">
-      <template #icon>
-        <book-outlined />
-      </template>
-      翻译
-    </a-menu-item>
-  </a-menu>
+  <header v-if="route.name !== 'login'" class="app-header">
+    <a-menu class="app-menu" v-model:selectedKeys="current" mode="horizontal" @click="handleMenuClick">
+      <a-menu-item key="home">
+        <template #icon>
+          <account-book-outlined />
+        </template>
+        策略
+      </a-menu-item>
+      <a-menu-item key="leagues">
+        <template #icon>
+          <team-outlined />
+        </template>
+        联赛
+      </a-menu-item>
+      <a-menu-item key="games">
+        <template #icon>
+          <trophy-outlined />
+        </template>
+        比赛
+      </a-menu-item>
+      <a-menu-item key="locales">
+        <template #icon>
+          <book-outlined />
+        </template>
+        翻译
+      </a-menu-item>
+    </a-menu>
+
+    <div class="header-actions">
+      <span class="username">{{ authState.user?.username }}</span>
+      <a-button type="text" @click="handleLogout">
+        <template #icon>
+          <logout-outlined />
+        </template>
+        退出
+      </a-button>
+    </div>
+  </header>
 
   <router-view v-slot="{ Component }">
     <keep-alive :include="['games', 'leagues', 'locales']">
@@ -58,4 +82,25 @@ const handleMenuClick = (e) => {
 </template>
 
 <style scoped>
+.app-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid rgba(5, 5, 5, 0.06);
+}
+.app-menu {
+  flex: 1;
+  min-width: 0;
+  border-bottom: 0;
+}
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 0 16px;
+  white-space: nowrap;
+}
+.username {
+  color: rgba(0, 0, 0, 0.65);
+}
 </style>

+ 30 - 0
web/src/router/index.js

@@ -3,6 +3,8 @@ import HomeView from '@/views/home.vue';
 import GamesView from '@/views/games.vue';
 import LeaguesView from '@/views/leagues.vue';
 import LocalesView from '@/views/locales.vue';
+import LoginView from '@/views/login.vue';
+import { authState, checkAuth } from '@/stores/auth';
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -11,6 +13,12 @@ const router = createRouter({
       path: '/',
       redirect: '/home'
     },
+    {
+      path: '/login',
+      name: 'login',
+      component: LoginView,
+      meta: { public: true },
+    },
     {
       path: '/home',
       name: 'home',
@@ -34,4 +42,26 @@ const router = createRouter({
   ],
 });
 
+router.beforeEach(async (to) => {
+  if (to.meta.public) {
+    if (!authState.checked) {
+      await checkAuth();
+    }
+    return authState.user ? { name: 'home' } : true;
+  }
+
+  if (!authState.checked) {
+    await checkAuth();
+  }
+
+  if (!authState.user) {
+    return {
+      name: 'login',
+      query: { redirect: to.fullPath },
+    };
+  }
+
+  return true;
+});
+
 export default router;

+ 35 - 0
web/src/stores/auth.js

@@ -0,0 +1,35 @@
+import { reactive } from 'vue';
+import api from '@/libs/api';
+
+export const authState = reactive({
+  checked: false,
+  user: null,
+});
+
+export const checkAuth = async () => {
+  try {
+    const res = await api.get('/api/auth/me');
+    authState.user = res.data.data;
+    return true;
+  }
+  catch {
+    authState.user = null;
+    return false;
+  }
+  finally {
+    authState.checked = true;
+  }
+};
+
+export const login = async (credentials) => {
+  const res = await api.post('/api/auth/login', credentials);
+  authState.user = res.data.data;
+  authState.checked = true;
+  return res.data.data;
+};
+
+export const logout = async () => {
+  await api.post('/api/auth/logout');
+  authState.user = null;
+  authState.checked = true;
+};

+ 6 - 6
web/src/views/games.vue

@@ -1,9 +1,9 @@
 <script setup>
-import axios from 'axios';
 import dayjs from 'dayjs';
 import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
 import { message } from 'ant-design-vue';
 import { ReloadOutlined, PlusOutlined, LinkOutlined, DisconnectOutlined } from '@ant-design/icons-vue';
+import api from '@/libs/api';
 
 const search = ref('');
 // const games = ref(null);
@@ -18,7 +18,7 @@ const onSearch = (value) => {
 };
 
 // const updateGames = async () => {
-//   return axios.get('/api/games/get_games').then(res => {
+//   return api.get('/api/games/get_games').then(res => {
 //     if (res.data.statusCode === 200) {
 //       games.value = res.data.data;
 //     }
@@ -71,7 +71,7 @@ const setGamesRelation = () => {
     platforms: selectedGames,
     timestamp: selectedGames.polymarket.timestamp,
   };
-  axios.post('/api/games/set_relation', gameRelation)
+  api.post('/api/games/set_relation', gameRelation)
   .then(res => {
     if (res.data.statusCode === 200) {
       message.success('添加成功');
@@ -94,7 +94,7 @@ const setGamesRelation = () => {
 };
 
 const removeGamesRelation = (relation) => {
-  axios.post('/api/games/remove_relation', { id: relation.id })
+  api.post('/api/games/remove_relation', { id: relation.id })
   .then(res => {
     if (res.data.statusCode === 200) {
       message.success('删除成功');
@@ -111,7 +111,7 @@ const removeGamesRelation = (relation) => {
 }
 
 const updateGamesRelations = async () => {
-  return axios.get('/api/games/get_relations')
+  return api.get('/api/games/get_relations')
   .then(res => {
     if (res.data.statusCode === 200) {
       gamesRelations.value = res.data.data;
@@ -390,4 +390,4 @@ onUnmounted(() => {
 .game-date-time {
   color: #666;
 }
-</style>
+</style>

+ 5 - 5
web/src/views/home.vue

@@ -1,9 +1,9 @@
 <script setup>
-import axios from 'axios';
 import dayjs from 'dayjs';
 import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
 import { message } from 'ant-design-vue';
 import { ReloadOutlined } from '@ant-design/icons-vue';
+import api from '@/libs/api';
 
 const search = ref('');
 const games = ref(null);
@@ -14,7 +14,7 @@ const onSearch = (value) => {
 };
 
 const updateSolutions = () => {
-  axios.get('/api/games/get_solutions', { params: { min_profit_rate: 0 } }).then(res => {
+  api.get('/api/games/get_solutions', { params: { min_profit_rate: 0 } }).then(res => {
     if (res.data.statusCode === 200) {
       games.value = res.data.data;
     }
@@ -29,7 +29,7 @@ const updateSolutions = () => {
 }
 
 const getSolutionIorsInfo = (sid) => {
-  axios.get('/api/games/get_solution_ior_info', { params: { sid } })
+  api.get('/api/games/get_solution_ior_info', { params: { sid } })
   .then(res => {
     if (res.data.statusCode === 200) {
       console.log(res.data.data);
@@ -46,7 +46,7 @@ const getSolutionIorsInfo = (sid) => {
 
 const betSolution = (sid, stake=0) => {
   console.log('betSolution', sid, stake);
-  axios.get('/api/games/bet_solution', { params: { sid, stake } })
+  api.get('/api/games/bet_solution', { params: { sid, stake } })
   .then(res => {
     if (res.data.statusCode === 200) {
       console.log('betSolution result', res.data.data);
@@ -170,4 +170,4 @@ onUnmounted(() => {
 .game-date-time {
   color: #666;
 }
-</style>
+</style>

+ 6 - 6
web/src/views/leagues.vue

@@ -1,8 +1,8 @@
 <script setup>
-import axios from 'axios';
 import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
 import { message } from 'ant-design-vue';
 import { ReloadOutlined, PlusOutlined, LinkOutlined, DisconnectOutlined } from '@ant-design/icons-vue';
+import api from '@/libs/api';
 
 const search = ref('');
 const leagues = ref(null);
@@ -17,7 +17,7 @@ const onSearch = (value) => {
 };
 
 const updateLeagues = () => {
-  axios.get('/api/games/get_leagues').then(res => {
+  api.get('/api/games/get_leagues').then(res => {
     if (res.data.statusCode === 200) {
       leagues.value = res.data.data;
     }
@@ -69,7 +69,7 @@ const setLeaguesRelation = () => {
     id: selectedLeagues.polymarket.id,
     platforms: selectedLeagues,
   };
-  axios.post('/api/games/set_leagues_relation', leagueRelation)
+  api.post('/api/games/set_leagues_relation', leagueRelation)
   .then(res => {
     if (res.data.statusCode === 200) {
       message.success('添加成功');
@@ -87,7 +87,7 @@ const setLeaguesRelation = () => {
 };
 
 const removeLeaguesRelation = (relation) => {
-  axios.post('/api/games/remove_leagues_relation', { id: relation.id })
+  api.post('/api/games/remove_leagues_relation', { id: relation.id })
   .then(res => {
     if (res.data.statusCode === 200) {
       message.success('删除成功');
@@ -104,7 +104,7 @@ const removeLeaguesRelation = (relation) => {
 }
 
 const getLeaguesRelations = () => {
-  axios.get('/api/games/get_leagues_relations')
+  api.get('/api/games/get_leagues_relations')
   .then(res => {
     if (res.data.statusCode === 200) {
       leaguesRelations.value = res.data.data;
@@ -317,4 +317,4 @@ onUnmounted(() => {
     font-size: 12px;
   }
 }
-</style>
+</style>

+ 4 - 4
web/src/views/locales.vue

@@ -1,8 +1,8 @@
 <script setup>
-import axios from 'axios';
 import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
 import { message } from 'ant-design-vue';
 import { ReloadOutlined, PlusOutlined } from '@ant-design/icons-vue';
+import api from '@/libs/api';
 
 const search = ref('');
 const locales = ref(null);
@@ -14,7 +14,7 @@ const onSearch = (value) => {
 };
 
 const updateLocales = () => {
-  axios.get('/api/locales/get_locales').then(res => {
+  api.get('/api/locales/get_locales').then(res => {
     if (res.data.statusCode === 200) {
       const data = res.data.data ?? {};
       locales.value = Object.keys(data).map(key => {
@@ -33,7 +33,7 @@ const updateLocales = () => {
 };
 
 const setLocales = (en, zh) => {
-  axios.post('/api/locales/set_locales', { [en]: zh }).then(res => {
+  api.post('/api/locales/set_locales', { [en]: zh }).then(res => {
     if (res.data.statusCode === 200) {
       message.success('设置成功');
     }
@@ -134,4 +134,4 @@ onUnmounted(() => {
   border-top: 1px solid rgba(5, 5, 5, 0.06);
 }
 
-</style>
+</style>

+ 85 - 0
web/src/views/login.vue

@@ -0,0 +1,85 @@
+<script setup>
+import { reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { message } from 'ant-design-vue';
+import { LockOutlined, UserOutlined } from '@ant-design/icons-vue';
+import { login } from '@/stores/auth';
+
+const route = useRoute();
+const router = useRouter();
+const loading = ref(false);
+const form = reactive({
+  username: '',
+  password: '',
+});
+
+const submit = async () => {
+  if (!form.username || !form.password) {
+    message.error('请输入用户名和密码');
+    return;
+  }
+
+  loading.value = true;
+  try {
+    await login(form);
+    const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/home';
+    router.replace(redirect);
+  }
+  catch (err) {
+    message.error(err.response?.data?.message ?? err.message);
+  }
+  finally {
+    loading.value = false;
+  }
+};
+</script>
+
+<template>
+  <div class="login-page">
+    <div class="login-panel">
+      <h1>PPAI</h1>
+      <a-form :model="form" layout="vertical" @finish="submit">
+        <a-form-item label="用户名" name="username" :rules="[{ required: true, message: '请输入用户名' }]">
+          <a-input v-model:value="form.username" size="large" autocomplete="username">
+            <template #prefix>
+              <user-outlined />
+            </template>
+          </a-input>
+        </a-form-item>
+        <a-form-item label="密码" name="password" :rules="[{ required: true, message: '请输入密码' }]">
+          <a-input-password v-model:value="form.password" size="large" autocomplete="current-password">
+            <template #prefix>
+              <lock-outlined />
+            </template>
+          </a-input-password>
+        </a-form-item>
+        <a-button type="primary" html-type="submit" size="large" :loading="loading" block>
+          登录
+        </a-button>
+      </a-form>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.login-page {
+  display: grid;
+  min-height: 100vh;
+  place-items: center;
+  background: #f5f7fb;
+}
+.login-panel {
+  width: min(380px, calc(100vw - 32px));
+  padding: 28px;
+  border: 1px solid rgba(5, 5, 5, 0.06);
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
+  h1 {
+    margin: 0 0 24px;
+    text-align: center;
+    font-size: 28px;
+    font-weight: 600;
+  }
+}
+</style>