diff --git a/server/README.md b/server/README.md index 3b927bc..3e4f316 100644 --- a/server/README.md +++ b/server/README.md @@ -26,41 +26,39 @@ server The configuration file path is `/etc/kvm/server.yaml`. ```yaml -proto: http +# Network Settings +proto: http # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http` port: - http: 80 - https: 443 + http: 80 # The listening port for the HTTP service. Default is `80` + https: 443 # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443` cert: - crt: server.crt - key: server.key + crt: server.crt # The path to the public key certificate for HTTPS + key: server.key # The path to the private key file for HTTPS -# Log level (debug/info/warn/error) -# Note: Use 'info' or 'error' in production, 'debug' only for development + +# Logging Configuration logger: - level: info - file: stdout + level: info # Global log output level. Evaluated options from highest to lowest detail: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. Default is `info` + file: stdout # Log output destination. `stdout` outputs to the standard console. A file path directs log output to that file. Default is `stdout` -# Authentication setting (enable/disable) -# Note: Only disable authentication in development environment -authentication: enable +# Authentication & Security +authentication: enable # Whether to enable identity verification for HTTP API and Web endpoints. Options are `enable` or `disable`. Default is `enable`. Highly recommended to leave this enabled for internet-facing devices! jwt: - # JWT secret key. If left empty, a random 64-byte key will be generated automatically. - secretKey: "" - # JWT token expiration time in seconds. Default: 2678400 (31 days) - refreshTokenDuration: 2678400 - # Invalidate all JWT tokens when the user logs out. Default: true - revokeTokensOnLogout: true + secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup + refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days) + revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true` +security: + loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0` + loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5` -# Address for custom STUN server -# Note: You can disable the STUN service by setting it to 'disable' (e.g., in a LAN environment) -stun: stun.l.google.com:19302 -# Address and authentication for custom TURN server +# WebRTC Traversal Settings +stun: stun.l.google.com:19302 # The default STUN server address used for NAT hole-punching to establish P2P streams turn: - turnAddr: example_addr - turnUser: example_user - turnCred: example_cred + turnAddr: example_addr # The relay (TURN) server address (format `ip:port`) used as a fallback when P2P connection fails. Leave empty to disable TURN relay + turnUser: example_user # The username required for authorization to the TURN server + turnCred: example_cred # The credential/password required for authorization to the TURN server ``` ## Compile & Deploy diff --git a/server/README_ZH.md b/server/README_ZH.md index ad8ec12..ed26a0a 100644 --- a/server/README_ZH.md +++ b/server/README_ZH.md @@ -24,40 +24,39 @@ server 配置文件路径为 `/etc/kvm/server.yaml`。 ```yaml -proto: http +# 网络设置 +proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https` port: - http: 80 - https: 443 + http: 80 # HTTP 服务的监听端口,默认为 `80` + https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443` cert: - crt: server.crt - key: server.key + crt: server.crt # HTTPS 服务使用的公钥证书路径 + key: server.key # HTTPS 服务使用的私钥文件路径 -# 日志级别(debug/info/warn/error) -# 注意:在生产环境中使用 info 或 error。debug 模式仅在开发环境中使用。 + +# 日志配置 logger: - level: info - file: stdout + level: info # 全局日志打印级别,从高到底可选 `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`。默认为 `info` + file: stdout # 日志输出目标位置。若填写 `stdout` 则输出在控制台。配置为文件路径则会输出到对应的文件。默认为 `stdout` -# 鉴权设置(enable/disable) -# 注意:生产环境中请勿使用 disable。 -authentication: enable +# 认证与安全 +authentication: enable # 是否开启 HTTP 接口与网页的身份校验。可选 `enable` (开启) 或 `disable` (禁用)。默认为 `enable`。强烈建议公开在互联网的机器开启此项! jwt: - # jwt 密钥。设置为空则使用随机生成的64位密钥 - secretKey: "" - # jwt token 过期时间(单位:秒),默认为2678400(31天) - refreshTokenDuration: 2678400 - # 在帐号登出时是否使所有 jwt token 失效。默认为 true - revokeTokensOnLogout: true + secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成 + refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天) + revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true` +security: + loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0` + loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5` -# 自定义 STUN 服务器的地址 -# 注意:可以设置为“disable”来禁用 STUN 服务(例如在局域网环境中使用时) -stun: stun.l.google.com:19302 +# WebRTC 内网穿透 +stun: stun.l.google.com:19302 # 默认使用的 STUN 服务器地址,用于打洞获取公网 IP 建立 P2P 流 turn: - turnAddr: example_addr - turnUser: example_user - turnCred: example_cred + turnAddr: example_addr # 当 P2P 直连失败时,作为备用的中继(TURN)服务器地址(格式如 `ip:port`)。留空表示不使用 TURN 中继 + turnUser: example_user # TURN 服务器授权连接时使用的用户名 + turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码 ``` ## 编译部署 diff --git a/server/config/default.go b/server/config/default.go index a841e9e..987c317 100644 --- a/server/config/default.go +++ b/server/config/default.go @@ -26,6 +26,10 @@ var defaultConfig = &Config{ TurnCred: "", }, Authentication: "enable", + Security: Security{ + LoginLockoutDuration: 0, + LoginMaxFailures: 5, + }, } func checkDefaultValue() { diff --git a/server/config/types.go b/server/config/types.go index 2f8f069..40df073 100644 --- a/server/config/types.go +++ b/server/config/types.go @@ -1,14 +1,15 @@ package config type Config struct { - Proto string `yaml:"proto"` - Port Port `yaml:"port"` - Cert Cert `yaml:"cert"` - Logger Logger `yaml:"logger"` - Authentication string `yaml:"authentication"` - JWT JWT `yaml:"jwt"` - Stun string `yaml:"stun"` - Turn Turn `yaml:"turn"` + Proto string `yaml:"proto"` + Port Port `yaml:"port"` + Cert Cert `yaml:"cert"` + Logger Logger `yaml:"logger"` + Authentication string `yaml:"authentication"` + JWT JWT `yaml:"jwt"` + Stun string `yaml:"stun"` + Turn Turn `yaml:"turn"` + Security Security `yaml:"security"` Hardware Hardware `yaml:"-"` } @@ -40,6 +41,11 @@ type Turn struct { TurnCred string `yaml:"turnCred"` } +type Security struct { + LoginLockoutDuration int `yaml:"loginLockoutDuration"` + LoginMaxFailures int `yaml:"loginMaxFailures"` +} + type Hardware struct { Version HWVersion `yaml:"-"` GPIOReset string `yaml:"-"` diff --git a/server/service/auth/brute_force.go b/server/service/auth/brute_force.go new file mode 100644 index 0000000..557c942 --- /dev/null +++ b/server/service/auth/brute_force.go @@ -0,0 +1,147 @@ +package auth + +import ( + "sync" + "time" + + "NanoKVM-Server/config" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +type loginAttempt struct { + failures int + lastFailed time.Time + lockoutEnd time.Time +} + +const ( + maxLoginAttemptsRecords = 3000 + cleanupInterval = 6 * time.Hour +) + +var ( + loginAttempts = make(map[string]*loginAttempt) + loginMutex sync.Mutex + cleanupOnce sync.Once +) + +// startCleanupRoutine starts a background routine to clean up memory +func startCleanupRoutine() { + conf := config.GetInstance() + if conf.Security.LoginLockoutDuration <= 0 { + return + } + + go func() { + ticker := time.NewTicker(cleanupInterval) + for range ticker.C { + loginMutex.Lock() + now := time.Now() + for ip, attempt := range loginAttempts { + // Cleanup rules: if it has been locked and the lockout time has passed, + // or (although not locked) it has been 30 minutes since the last failure, + // remove this record + if (!attempt.lockoutEnd.IsZero() && now.After(attempt.lockoutEnd)) || + (attempt.lockoutEnd.IsZero() && now.Sub(attempt.lastFailed) > 30*time.Minute) { + delete(loginAttempts, ip) + } + } + loginMutex.Unlock() + } + }() +} + +// GetClientIP gets a reliable real IP +func GetClientIP(c *gin.Context) string { + ip := c.RemoteIP() + if ip == "" { + ip = c.ClientIP() + } + return ip +} + +// CheckLoginAttempt checks if a login attempt is allowed based on brute-force protection rules. +// Returning true means the IP/System is locked out, and an error string and error code are returned. +func CheckLoginAttempt(clientIP string) (bool, int, string) { + conf := config.GetInstance() + if conf.Security.LoginLockoutDuration <= 0 { + return false, 0, "" + } + + cleanupOnce.Do(startCleanupRoutine) + + loginMutex.Lock() + defer loginMutex.Unlock() + + if attempt, exists := loginAttempts[clientIP]; exists { + if time.Now().Before(attempt.lockoutEnd) { + log.Debugf("login blocked for IP %s: account locked due to too many failed attempts (until %s)", clientIP, attempt.lockoutEnd) + return true, -5, "Account locked due to too many failed attempts, please try again later" + } + + // If lockout has elapsed, then we reset the failures and lockoutEnd. + if !attempt.lockoutEnd.IsZero() { + attempt.failures = 0 + attempt.lockoutEnd = time.Time{} + } + } + + return false, 0, "" +} + +// RecordLoginFailure records a failed login attempt for the given IP address. +func RecordLoginFailure(clientIP string) (bool, int, string) { + conf := config.GetInstance() + if conf.Security.LoginLockoutDuration <= 0 { + return false, 0, "" + } + + cleanupOnce.Do(startCleanupRoutine) + + loginMutex.Lock() + defer loginMutex.Unlock() + + attempt, exists := loginAttempts[clientIP] + if !exists { + // When the record pool is full, clear the records instead of global lockout to prevent DDoS + if len(loginAttempts) >= maxLoginAttemptsRecords { + log.Warn("Login attempt records reached maximum limit, clearing records to prevent memory overflow") + loginAttempts = make(map[string]*loginAttempt) + } + attempt = &loginAttempt{} + loginAttempts[clientIP] = attempt + } + + now := time.Now() + // Failure time window: if it has been a long time since the last failure + // (e.g., beyond the lockoutDuration window), reset the failure count + if !attempt.lastFailed.IsZero() && now.Sub(attempt.lastFailed) > time.Duration(conf.Security.LoginLockoutDuration)*time.Second { + attempt.failures = 0 + } + + attempt.failures++ + attempt.lastFailed = now + + // Reach the failure limit, lock out + if attempt.failures >= conf.Security.LoginMaxFailures { + attempt.lockoutEnd = now.Add(time.Duration(conf.Security.LoginLockoutDuration) * time.Second) + log.Debugf("login failures reached threshold for IP %s, locking out until %s", clientIP, attempt.lockoutEnd) + } + + return false, 0, "" +} + +// ClearLoginAttempt clears the failed login attempt record for an IP upon successful login. +func ClearLoginAttempt(clientIP string) { + conf := config.GetInstance() + if conf.Security.LoginLockoutDuration <= 0 { + return + } + + loginMutex.Lock() + defer loginMutex.Unlock() + + delete(loginAttempts, clientIP) +} diff --git a/server/service/auth/login.go b/server/service/auth/login.go index c933ec1..9e566d6 100644 --- a/server/service/auth/login.go +++ b/server/service/auth/login.go @@ -1,10 +1,11 @@ package auth import ( + "time" + "NanoKVM-Server/config" "NanoKVM-Server/middleware" "NanoKVM-Server/proto" - "time" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" @@ -23,6 +24,13 @@ func (s *Service) Login(c *gin.Context) { return } + clientIP := GetClientIP(c) + if locked, code, msg := CheckLoginAttempt(clientIP); locked { + time.Sleep(3 * time.Second) + rsp.ErrRsp(c, code, msg) + return + } + if err := proto.ParseFormRequest(c, &req); err != nil { time.Sleep(3 * time.Second) rsp.ErrRsp(c, -1, "invalid parameters") @@ -31,10 +39,18 @@ func (s *Service) Login(c *gin.Context) { if ok := CompareAccount(req.Username, req.Password); !ok { time.Sleep(2 * time.Second) + + if locked, code, msg := RecordLoginFailure(clientIP); locked { + rsp.ErrRsp(c, code, msg) + return + } + rsp.ErrRsp(c, -2, "invalid username or password") return } + ClearLoginAttempt(clientIP) + token, err := middleware.GenerateJWT(req.Username) if err != nil { time.Sleep(1 * time.Second) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index a1e39cd..2969f36 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -16,6 +16,8 @@ const en = { noEmptyPassword: 'Password required', noAccount: 'Failed to get user information, please refresh web page or reset password', invalidUser: 'Invalid username or password', + locked: 'Too many logins, please try again later', + globalLocked: 'System under protection, please try again later', error: 'Unexpected error', changePassword: 'Change Password', changePasswordDesc: 'For the security of your device, please change the password!', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 044bd7c..13eed26 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -16,6 +16,8 @@ const zh = { noEmptyPassword: '密码不能为空', noAccount: '获取用户信息失败,请刷新重试或重置密码', invalidUser: '用户名或密码错误', + locked: '登录太频繁,请稍后再试', + globalLocked: '系统防爆破保护中,请稍后再试', error: '未知错误', changePassword: '修改密码', changePasswordDesc: '为了您的设备安全,请修改密码!', diff --git a/web/src/pages/auth/login/index.tsx b/web/src/pages/auth/login/index.tsx index a94a881..a5f7aea 100644 --- a/web/src/pages/auth/login/index.tsx +++ b/web/src/pages/auth/login/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, ReactElement } from 'react'; +import { ReactElement, useEffect, useState } from 'react'; import { LockOutlined, UserOutlined } from '@ant-design/icons'; import { Button, Form, Input } from 'antd'; import { useTranslation } from 'react-i18next'; @@ -41,7 +41,12 @@ export const Login = (): ReactElement => { .login(username, password) .then((rsp: any) => { if (rsp.code !== 0) { - setMsg(rsp.code === -2 ? t('auth.invalidUser') : t('auth.error')); + let errorMsg = t('auth.error'); + if (rsp.code === -2) errorMsg = t('auth.invalidUser'); + else if (rsp.code === -5) errorMsg = t('auth.locked'); + else if (rsp.code === -4) errorMsg = t('auth.globalLocked'); + + setMsg(errorMsg); return; } @@ -80,7 +85,8 @@ export const Login = (): ReactElement => { setTimeout(() => { (evt.target as HTMLImageElement).classList.remove('animate-spin'); }, 1000); - }} /> + }} + /> { /> -
{msg}
+
{msg}