diff --git a/.gitignore b/.gitignore index 0a1cf2d..b30ca97 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ frontend/node_modules/ .env .env.local .env.*.local + +lmvpnweb.sock \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a05badb --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# lmvpn_server + +## 目录说明 + +- `data/` - 程序运行生成的数据(配置文件、数据库文件) +- `dist/` - 前端构建产物,由 Go 服务端托管提供静态资源 +- `frontend/` - 前端工程(Vue 3 + TypeScript + Vite) +- `internal/` - Go 后端源码 + - `config/` - 配置加载 + - `db/` - 数据库操作 + - `handler/` - HTTP 请求处理 + - `middleware/` - 中间件(认证等) + - `model/` - 数据模型 + - `vpn/` - VPN 相关逻辑(认证、隧道等) +- `pytest/` - Python 测试脚本 +- `main.go` - Go 服务端入口文件 +- `go.mod` / `go.sum` - Go 模块依赖管理 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7905b05..b2b7781 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,14 @@ @@ -8,11 +16,16 @@ import HelloWorld from './components/HelloWorld.vue' - - Home About + + 管理后台 + 退出 + + + 登录 + @@ -50,6 +63,8 @@ nav a { display: inline-block; padding: 0 1rem; border-left: 1px solid var(--color-border); + color: var(--color-text); + text-decoration: none; } nav a:first-of-type { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3e49915..51d7d49 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' +import { useAuthStore } from '@/stores/auth' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -12,12 +13,32 @@ const router = createRouter({ { path: '/about', name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. component: () => import('../views/AboutView.vue'), }, + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue'), + }, + { + path: '/admin', + name: 'admin', + component: () => import('../views/AdminView.vue'), + meta: { requiresAuth: true }, + }, ], }) +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isLoggedIn) { + next({ name: 'login' }) + } else if (to.name === 'login' && authStore.isLoggedIn) { + next({ name: 'admin' }) + } else { + next() + } +}) + export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..111656b --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,63 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +interface UserInfo { + id: number + username: string + role: string +} + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token') || '') + const user = ref(null) + + const isLoggedIn = computed(() => !!token.value) + + function setAuth(t: string, u: UserInfo) { + token.value = t + user.value = u + localStorage.setItem('token', t) + } + + function clearAuth() { + token.value = '' + user.value = null + localStorage.removeItem('token') + } + + async function login(username: string, password: string) { + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || '登录失败') + } + const data = await res.json() + setAuth(data.token, data.user) + } + + function logout() { + clearAuth() + } + + async function fetchUser() { + if (!token.value) return + try { + const res = await fetch('/api/me', { + headers: { Authorization: `Bearer ${token.value}` }, + }) + if (!res.ok) { + clearAuth() + return + } + user.value = await res.json() + } catch { + clearAuth() + } + } + + return { token, user, isLoggedIn, login, logout, fetchUser } +}) diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..bed9bb6 --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,67 @@ + + + + + 管理后台 + + 用户名:{{ authStore.user?.username }} + 角色:{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }} + + 退出登录 + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..a026dc6 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,141 @@ + + + + + + LmVPN 登录 + + + 用户名 + + + + 密码 + + + {{ error }} + + {{ loading ? '登录中...' : '登录' }} + + + + + + + diff --git a/go.mod b/go.mod index 44c1f67..eb7dab9 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect diff --git a/go.sum b/go.sum index 911c9ca..90730b6 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..e5efc63 --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,79 @@ +package handler + +import ( + "net/http" + + "lmvpn/internal/db" + "lmvpn/internal/middleware" + "lmvpn/internal/model" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type loginResponse struct { + Token string `json:"token"` + User userInfo `json:"user"` +} + +type userInfo struct { + ID uint `json:"id"` + Username string `json:"username"` + Role string `json:"role"` +} + +func Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请输入用户名和密码"}) + return + } + + var user model.User + if err := db.DB.Where("username = ?", req.Username).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + + if user.Status != 1 { + c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"}) + return + } + + token, err := middleware.GenerateToken(user.ID, user.Username, user.Role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"}) + return + } + + c.JSON(http.StatusOK, loginResponse{ + Token: token, + User: userInfo{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + }, + }) +} + +func Me(c *gin.Context) { + userID, _ := c.Get("user_id") + username, _ := c.Get("username") + role, _ := c.Get("role") + + c.JSON(http.StatusOK, userInfo{ + ID: userID.(uint), + Username: username.(string), + Role: role.(string), + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..df2d1ce --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +const ( + jwtSecret = "lmvpn-jwt-secret-key-2024" + tokenExpire = 24 * time.Hour +) + +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateToken(userID uint, username, role string) (string, error) { + claims := Claims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenExpire)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(jwtSecret)) +} + +func ParseToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, jwt.ErrSignatureInvalid +} + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"}) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "认证格式错误"}) + c.Abort() + return + } + + claims, err := ParseToken(parts[1]) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"}) + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + c.Next() + } +} diff --git a/main.go b/main.go index 60b3cfd..a18f91f 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,8 @@ import ( "lmvpn/internal/config" "lmvpn/internal/db" + "lmvpn/internal/handler" + "lmvpn/internal/middleware" "lmvpn/internal/vpn" "github.com/gin-gonic/gin" @@ -30,6 +32,14 @@ func main() { r.GET("/ws", vpn.HandleWS) + r.POST("/api/login", handler.Login) + + auth := r.Group("/api") + auth.Use(middleware.AuthMiddleware()) + { + auth.GET("/me", handler.Me) + } + fs := http.FileServer(http.Dir("./dist")) r.Use(func(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, "/ws") { diff --git a/pytest/conftest.py b/pytest/conftest.py new file mode 100644 index 0000000..ef5cc75 --- /dev/null +++ b/pytest/conftest.py @@ -0,0 +1,34 @@ +import os +import json +import pytest +import logging +from websockets.asyncio.client import connect + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +WS_URL = os.environ.get("LMVPN_WS_URL", "ws://localhost:8080/ws") +AUTH_USERNAME = os.environ.get("LMVPN_AUTH_USERNAME", "admin") +AUTH_PASSWORD = os.environ.get("LMVPN_AUTH_PASSWORD", "admin123") + + +@pytest.fixture +async def raw_ws(): + """仅建立 WebSocket 连接,不认证""" + logger.info("connecting to %s", WS_URL) + async with connect(WS_URL, ping_interval=None) as ws: + yield ws + + +@pytest.fixture +async def authenticated_ws(): + """建立 WebSocket 连接并完成认证""" + logger.info("connecting to %s", WS_URL) + async with connect(WS_URL, ping_interval=None) as ws: + auth_msg = json.dumps({"type": "auth", "username": AUTH_USERNAME, "password": AUTH_PASSWORD}) + await ws.send(auth_msg) + resp = await ws.recv() + resp_data = json.loads(resp) + assert resp_data.get("type") == "auth_ok", f"认证失败: {resp_data}" + logger.info("authenticated as %s", AUTH_USERNAME) + yield ws diff --git a/pytest/pytest.ini b/pytest/pytest.ini new file mode 100644 index 0000000..e4c4478 --- /dev/null +++ b/pytest/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = pytest diff --git a/pytest/requirements.txt b/pytest/requirements.txt new file mode 100644 index 0000000..a09af33 --- /dev/null +++ b/pytest/requirements.txt @@ -0,0 +1,3 @@ +pytest>=8.0 +pytest-asyncio>=0.24 +websockets>=13.0 diff --git a/pytest/websocket_test.py b/pytest/websocket_test.py new file mode 100644 index 0000000..1d45915 --- /dev/null +++ b/pytest/websocket_test.py @@ -0,0 +1,142 @@ +import json +import asyncio +import pytest +from websockets import ConnectionClosed + +pytestmark = pytest.mark.asyncio + + +# ============================================================ +# 认证测试 +# ============================================================ + +async def test_auth_success(raw_ws): + """正确凭据应返回 auth_ok""" + msg = json.dumps({"type": "auth", "username": "admin", "password": "admin123"}) + await raw_ws.send(msg) + resp = await raw_ws.recv() + data = json.loads(resp) + assert data["type"] == "auth_ok" + + +async def test_auth_wrong_password(raw_ws): + """错误密码应返回 auth_err""" + msg = json.dumps({"type": "auth", "username": "admin", "password": "wrong"}) + await raw_ws.send(msg) + resp = await raw_ws.recv() + data = json.loads(resp) + assert data["type"] == "auth_err" + + +async def test_auth_nonexistent_user(raw_ws): + """不存在的用户名应返回 auth_err""" + msg = json.dumps({"type": "auth", "username": "no_such_user", "password": "123456"}) + await raw_ws.send(msg) + resp = await raw_ws.recv() + data = json.loads(resp) + assert data["type"] == "auth_err" + + +async def test_auth_non_json(raw_ws): + """首条消息不是 JSON,服务端应关闭连接""" + await raw_ws.send("not json") + with pytest.raises(ConnectionClosed): + await raw_ws.recv() + + +async def test_auth_missing_type(raw_ws): + """缺少 type 字段,服务端应关闭连接""" + msg = json.dumps({"username": "admin", "password": "admin123"}) + await raw_ws.send(msg) + with pytest.raises(ConnectionClosed): + await raw_ws.recv() + + +# ============================================================ +# 消息回显测试 +# ============================================================ + +async def test_echo_text(authenticated_ws): + """发送文本消息,验证回显一致""" + await authenticated_ws.send("hello world") + resp = await authenticated_ws.recv() + assert resp == "hello world" + + +async def test_echo_binary(authenticated_ws): + """发送二进制消息,验证回显一致""" + data = b"\x00\x01\x02\xff\xfe\xfd" + await authenticated_ws.send(data) + resp = await authenticated_ws.recv() + assert resp == data + + +async def test_echo_multiple_messages(authenticated_ws): + """连续发送多条消息,逐一验证回显""" + messages = ["msg1", "msg2", "msg3", "hello", "世界"] + for m in messages: + await authenticated_ws.send(m) + resp = await authenticated_ws.recv() + assert resp == m + + +async def test_echo_json_message(authenticated_ws): + """发送 JSON 消息,验证回显一致""" + payload = json.dumps({"cmd": "ping", "seq": 1, "data": "test"}) + await authenticated_ws.send(payload) + resp = await authenticated_ws.recv() + assert resp == payload + + +async def test_echo_large_message(authenticated_ws): + """发送大消息(64KB),验证回显一致""" + large_msg = "A" * 65536 + await authenticated_ws.send(large_msg) + resp = await authenticated_ws.recv() + assert resp == large_msg + + +# ============================================================ +# 并发测试 +# ============================================================ + +async def test_concurrent_connections(): + """同时建立 5 个认证连接,各自发送消息验证回显""" + from websockets.asyncio.client import connect + import os + + ws_url = os.environ.get("LMVPN_WS_URL", "ws://localhost:8080/ws") + + async def one_session(uid): + async with connect(ws_url, ping_interval=None) as ws: + auth = json.dumps({"type": "auth", "username": "admin", "password": "admin123"}) + await ws.send(auth) + resp = await ws.recv() + assert json.loads(resp)["type"] == "auth_ok" + + msg = f"msg_from_{uid}" + await ws.send(msg) + resp = await ws.recv() + assert resp == msg + + tasks = [one_session(i) for i in range(5)] + await asyncio.gather(*tasks) + + +# ============================================================ +# 协议测试 +# ============================================================ + +async def test_connection_close_after_auth_failure(raw_ws): + """认证失败后服务端应关闭连接""" + msg = json.dumps({"type": "auth", "username": "admin", "password": "wrong"}) + await raw_ws.send(msg) + resp = await raw_ws.recv() + assert json.loads(resp)["type"] == "auth_err" + with pytest.raises(ConnectionClosed): + await raw_ws.recv() + + +async def test_ping_pong(authenticated_ws): + """发送 ping,服务端应在超时前回应 pong""" + await authenticated_ws.ping()
用户名:{{ authStore.user?.username }}
角色:{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }}
{{ error }}