mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 13:44:24 +00:00
fix: harden registration with rate limiting, input validation, and security fixes
- Add per-IP rate limiter middleware (5 req/min) on /register endpoint - Validate username (3-64 chars) and password (8-128 chars) with trim - Use sentinel error ErrUsernameAlreadyExists instead of string matching - Prevent TurnstileSecretKey exposure via admin settings API (json:"-") - Skip json:"-" fields in UpdateAllSetting to avoid overwriting secrets - Add SetTurnstileSecretKey setter for programmatic configuration - Reuse package-level http.Client in Turnstile verification for connection pooling - Add io.LimitReader to cap Turnstile response body size - Log all Turnstile verification error paths for debugging - Add invalidUsername/invalidPassword i18n keys to all 13 locales
This commit is contained in:
parent
b4047cee54
commit
90665c92f4
19 changed files with 148 additions and 9 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -8,6 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
|
|
@ -52,7 +54,7 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/logout", a.logout)
|
g.GET("/logout", a.logout)
|
||||||
|
|
||||||
g.POST("/login", a.login)
|
g.POST("/login", a.login)
|
||||||
g.POST("/register", a.register)
|
g.POST("/register", middleware.RateLimitMiddleware(5, time.Minute), a.register)
|
||||||
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
|
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
|
||||||
g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey)
|
g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey)
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +132,11 @@ func (a *IndexController) register(c *gin.Context) {
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
form.Username = strings.TrimSpace(form.Username)
|
||||||
|
form.Password = strings.TrimSpace(form.Password)
|
||||||
|
|
||||||
if form.Username == "" {
|
if form.Username == "" {
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername"))
|
||||||
return
|
return
|
||||||
|
|
@ -138,6 +145,14 @@ func (a *IndexController) register(c *gin.Context) {
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(form.Username) < 3 || len(form.Username) > 64 {
|
||||||
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidUsername"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(form.Password) < 8 || len(form.Password) > 128 {
|
||||||
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidPassword"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Verify Turnstile token if site key is configured
|
// Verify Turnstile token if site key is configured
|
||||||
turnstileSecretKey, err := a.settingService.GetTurnstileSecretKey()
|
turnstileSecretKey, err := a.settingService.GetTurnstileSecretKey()
|
||||||
|
|
@ -154,8 +169,7 @@ func (a *IndexController) register(c *gin.Context) {
|
||||||
|
|
||||||
err = a.userService.RegisterUser(form.Username, form.Password, &a.inboundService)
|
err = a.userService.RegisterUser(form.Username, form.Password, &a.inboundService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
if errors.Is(err, service.ErrUsernameAlreadyExists) {
|
||||||
if strings.Contains(errMsg, "already exists") {
|
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.userExists"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.userExists"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ type AllSetting struct {
|
||||||
|
|
||||||
// Registration settings
|
// Registration settings
|
||||||
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
|
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
|
||||||
TurnstileSecretKey string `json:"turnstileSecretKey" form:"turnstileSecretKey"`
|
TurnstileSecretKey string `json:"-" form:"-"` // server-side only, never sent to frontend
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
||||||
|
|
|
||||||
82
web/middleware/ratelimit.go
Normal file
82
web/middleware/ratelimit.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateEntry struct {
|
||||||
|
count int
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitMiddleware returns a Gin middleware that limits requests per IP.
|
||||||
|
// maxRequests is the maximum number of requests allowed within the window.
|
||||||
|
func RateLimitMiddleware(maxRequests int, window time.Duration) gin.HandlerFunc {
|
||||||
|
var mu sync.Mutex
|
||||||
|
entries := make(map[string]*rateEntry)
|
||||||
|
|
||||||
|
// Periodically evict stale entries to prevent unbounded memory growth
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(window)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
mu.Lock()
|
||||||
|
cutoff := time.Now().Add(-window * 2)
|
||||||
|
for ip, e := range entries {
|
||||||
|
if e.lastSeen.Before(cutoff) {
|
||||||
|
delete(entries, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ip := c.GetHeader("X-Real-IP")
|
||||||
|
if ip == "" {
|
||||||
|
ip = c.GetHeader("X-Forwarded-For")
|
||||||
|
if ip != "" {
|
||||||
|
// Take the first IP from X-Forwarded-For
|
||||||
|
if idx := len(ip); idx > 0 {
|
||||||
|
for i, ch := range ip {
|
||||||
|
if ch == ',' {
|
||||||
|
ip = ip[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ip == "" {
|
||||||
|
ip = c.Request.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
e, exists := entries[ip]
|
||||||
|
if !exists || now.Sub(e.lastSeen) > window {
|
||||||
|
entries[ip] = &rateEntry{count: 1, lastSeen: now}
|
||||||
|
mu.Unlock()
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.lastSeen = now
|
||||||
|
e.count++
|
||||||
|
if e.count > maxRequests {
|
||||||
|
mu.Unlock()
|
||||||
|
c.JSON(http.StatusTooManyRequests, entity.Msg{
|
||||||
|
Success: false,
|
||||||
|
Msg: "Too many requests",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -959,6 +959,10 @@ func (s *SettingService) GetTurnstileSecretKey() (string, error) {
|
||||||
return s.getString("turnstileSecretKey")
|
return s.getString("turnstileSecretKey")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetTurnstileSecretKey(value string) error {
|
||||||
|
return s.setString("turnstileSecretKey", value)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||||
if err := allSetting.CheckValid(); err != nil {
|
if err := allSetting.CheckValid(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -974,6 +978,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||||
fields := reflect_util.GetFields(t)
|
fields := reflect_util.GetFields(t)
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
key := field.Tag.Get("json")
|
key := field.Tag.Get("json")
|
||||||
|
if key == "-" || key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
fieldV := v.FieldByName(field.Name)
|
fieldV := v.FieldByName(field.Name)
|
||||||
settings[key] = fmt.Sprint(fieldV.Interface())
|
settings[key] = fmt.Sprint(fieldV.Interface())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,14 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||||
|
|
||||||
|
var turnstileClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
type turnstileResponse struct {
|
type turnstileResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
|
|
@ -24,20 +28,22 @@ func VerifyTurnstile(secretKey, token, remoteIP string) bool {
|
||||||
form.Set("remoteip", remoteIP)
|
form.Set("remoteip", remoteIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
resp, err := turnstileClient.PostForm(turnstileVerifyURL, form)
|
||||||
resp, err := client.PostForm(turnstileVerifyURL, form)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Warning("Turnstile verification request failed (network error):", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Warning("Turnstile verification failed to read response:", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var result turnstileResponse
|
var result turnstileResponse
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
logger.Warning("Turnstile verification failed to parse response:", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return result.Success
|
return result.Success
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import (
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrUsernameAlreadyExists is returned when a user tries to register with a taken username.
|
||||||
|
var ErrUsernameAlreadyExists = errors.New("username already exists")
|
||||||
|
|
||||||
// UserService provides business logic for user management and authentication.
|
// UserService provides business logic for user management and authentication.
|
||||||
// It handles user creation, login, password management, and 2FA operations.
|
// It handles user creation, login, password management, and 2FA operations.
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
|
|
@ -152,8 +155,9 @@ func (s *UserService) RegisterUser(username string, password string, inboundServ
|
||||||
Role: "user",
|
Role: "user",
|
||||||
}
|
}
|
||||||
if err := tx.Create(user).Error; err != nil {
|
if err := tx.Create(user).Error; err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") {
|
errMsg := err.Error()
|
||||||
return errors.New("username already exists")
|
if strings.Contains(errMsg, "UNIQUE constraint failed") || strings.Contains(errMsg, "Duplicate") {
|
||||||
|
return ErrUsernameAlreadyExists
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول."
|
"successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول."
|
||||||
"userExists" = "اسم المستخدم موجود بالفعل"
|
"userExists" = "اسم المستخدم موجود بالفعل"
|
||||||
"errorRegister" = "فشل التسجيل"
|
"errorRegister" = "فشل التسجيل"
|
||||||
|
"invalidUsername" = "يجب أن يكون اسم المستخدم بين 3 و 64 حرفًا"
|
||||||
|
"invalidPassword" = "يجب أن تكون كلمة المرور بين 8 و 128 حرفًا"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "نظرة عامة"
|
"title" = "نظرة عامة"
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@
|
||||||
"successRegister" = "Registration successful, please log in."
|
"successRegister" = "Registration successful, please log in."
|
||||||
"userExists" = "Username already exists"
|
"userExists" = "Username already exists"
|
||||||
"errorRegister" = "Registration failed"
|
"errorRegister" = "Registration failed"
|
||||||
|
"invalidUsername" = "Username must be 3-64 characters"
|
||||||
|
"invalidPassword" = "Password must be 8-128 characters"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Overview"
|
"title" = "Overview"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "Registro exitoso, por favor inicia sesión."
|
"successRegister" = "Registro exitoso, por favor inicia sesión."
|
||||||
"userExists" = "El nombre de usuario ya existe"
|
"userExists" = "El nombre de usuario ya existe"
|
||||||
"errorRegister" = "Error en el registro"
|
"errorRegister" = "Error en el registro"
|
||||||
|
"invalidUsername" = "El nombre de usuario debe tener entre 3 y 64 caracteres"
|
||||||
|
"invalidPassword" = "La contraseña debe tener entre 8 y 128 caracteres"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Estado del Sistema"
|
"title" = "Estado del Sistema"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "ثبتنام با موفقیت انجام شد، لطفاً وارد شوید."
|
"successRegister" = "ثبتنام با موفقیت انجام شد، لطفاً وارد شوید."
|
||||||
"userExists" = "نام کاربری از قبل وجود دارد"
|
"userExists" = "نام کاربری از قبل وجود دارد"
|
||||||
"errorRegister" = "ثبت نام ناموفق بود"
|
"errorRegister" = "ثبت نام ناموفق بود"
|
||||||
|
"invalidUsername" = "نام کاربری باید بین ۳ تا ۶۴ کاراکتر باشد"
|
||||||
|
"invalidPassword" = "رمز عبور باید بین ۸ تا ۱۲۸ کاراکتر باشد"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "نمای کلی"
|
"title" = "نمای کلی"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "Pendaftaran berhasil, silakan masuk."
|
"successRegister" = "Pendaftaran berhasil, silakan masuk."
|
||||||
"userExists" = "Nama pengguna sudah ada"
|
"userExists" = "Nama pengguna sudah ada"
|
||||||
"errorRegister" = "Pendaftaran gagal"
|
"errorRegister" = "Pendaftaran gagal"
|
||||||
|
"invalidUsername" = "Nama pengguna harus 3-64 karakter"
|
||||||
|
"invalidPassword" = "Kata sandi harus 8-128 karakter"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Ikhtisar"
|
"title" = "Ikhtisar"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "登録が完了しました。ログインしてください。"
|
"successRegister" = "登録が完了しました。ログインしてください。"
|
||||||
"userExists" = "ユーザー名は既に存在します"
|
"userExists" = "ユーザー名は既に存在します"
|
||||||
"errorRegister" = "登録に失敗しました"
|
"errorRegister" = "登録に失敗しました"
|
||||||
|
"invalidUsername" = "ユーザー名は3〜64文字で入力してください"
|
||||||
|
"invalidPassword" = "パスワードは8〜128文字で入力してください"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "システムステータス"
|
"title" = "システムステータス"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "Registro bem-sucedido, por favor faça login."
|
"successRegister" = "Registro bem-sucedido, por favor faça login."
|
||||||
"userExists" = "Nome de usuário já existe"
|
"userExists" = "Nome de usuário já existe"
|
||||||
"errorRegister" = "Falha no registro"
|
"errorRegister" = "Falha no registro"
|
||||||
|
"invalidUsername" = "O nome de usuário deve ter entre 3 e 64 caracteres"
|
||||||
|
"invalidPassword" = "A senha deve ter entre 8 e 128 caracteres"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Visão Geral"
|
"title" = "Visão Geral"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "Регистрация прошла успешно, пожалуйста, войдите."
|
"successRegister" = "Регистрация прошла успешно, пожалуйста, войдите."
|
||||||
"userExists" = "Имя пользователя уже существует"
|
"userExists" = "Имя пользователя уже существует"
|
||||||
"errorRegister" = "Ошибка регистрации"
|
"errorRegister" = "Ошибка регистрации"
|
||||||
|
"invalidUsername" = "Имя пользователя должно содержать от 3 до 64 символов"
|
||||||
|
"invalidPassword" = "Пароль должен содержать от 8 до 128 символов"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Дашборд"
|
"title" = "Дашборд"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "Kayıt başarılı, lütfen giriş yapın."
|
"successRegister" = "Kayıt başarılı, lütfen giriş yapın."
|
||||||
"userExists" = "Kullanıcı adı zaten mevcut"
|
"userExists" = "Kullanıcı adı zaten mevcut"
|
||||||
"errorRegister" = "Kayıt başarısız"
|
"errorRegister" = "Kayıt başarısız"
|
||||||
|
"invalidUsername" = "Kullanıcı adı 3-64 karakter olmalıdır"
|
||||||
|
"invalidPassword" = "Şifre 8-128 karakter olmalıdır"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Genel Bakış"
|
"title" = "Genel Bakış"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть."
|
"successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть."
|
||||||
"userExists" = "Ім'я користувача вже існує"
|
"userExists" = "Ім'я користувача вже існує"
|
||||||
"errorRegister" = "Помилка реєстрації"
|
"errorRegister" = "Помилка реєстрації"
|
||||||
|
"invalidUsername" = "Ім'я користувача повинно містити від 3 до 64 символів"
|
||||||
|
"invalidPassword" = "Пароль повинен містити від 8 до 128 символів"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Огляд"
|
"title" = "Огляд"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "Đăng ký thành công, vui lòng đăng nhập."
|
"successRegister" = "Đăng ký thành công, vui lòng đăng nhập."
|
||||||
"userExists" = "Tên người dùng đã tồn tại"
|
"userExists" = "Tên người dùng đã tồn tại"
|
||||||
"errorRegister" = "Đăng ký thất bại"
|
"errorRegister" = "Đăng ký thất bại"
|
||||||
|
"invalidUsername" = "Tên người dùng phải từ 3-64 ký tự"
|
||||||
|
"invalidPassword" = "Mật khẩu phải từ 8-128 ký tự"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "Trạng thái hệ thống"
|
"title" = "Trạng thái hệ thống"
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@
|
||||||
"successRegister" = "注册成功,请登录。"
|
"successRegister" = "注册成功,请登录。"
|
||||||
"userExists" = "用户名已存在"
|
"userExists" = "用户名已存在"
|
||||||
"errorRegister" = "注册失败"
|
"errorRegister" = "注册失败"
|
||||||
|
"invalidUsername" = "用户名长度必须为3-64个字符"
|
||||||
|
"invalidPassword" = "密码长度必须为8-128个字符"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "系统状态"
|
"title" = "系统状态"
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"successRegister" = "註冊成功,請登入。"
|
"successRegister" = "註冊成功,請登入。"
|
||||||
"userExists" = "使用者名稱已存在"
|
"userExists" = "使用者名稱已存在"
|
||||||
"errorRegister" = "註冊失敗"
|
"errorRegister" = "註冊失敗"
|
||||||
|
"invalidUsername" = "使用者名稱長度必須為3-64個字元"
|
||||||
|
"invalidPassword" = "密碼長度必須為8-128個字元"
|
||||||
|
|
||||||
[pages.index]
|
[pages.index]
|
||||||
"title" = "系統狀態"
|
"title" = "系統狀態"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue