feat: add user registration with role-based access

- Add Role field to User model (admin/user) with uniqueIndex on Username
- Add POST /register endpoint with optional Cloudflare Turnstile verification
- Add RegisterUser service with bcrypt password hashing and duplicate detection
- Set default admin user role to "admin", new registrations get "user"
- Add turnstileSecretKey setting and GetTurnstileSecretKey getter
- Add i18n keys (userExists, errorRegister) to all 13 translation files
This commit is contained in:
Sora39831 2026-04-02 23:49:30 +08:00
parent 5729cebb8e
commit 5f83415e95
20 changed files with 365 additions and 7 deletions

View file

@ -66,6 +66,7 @@ func initUser() error {
user := &model.User{
Username: defaultUsername,
Password: hashedPassword,
Role: "admin",
}
return db.Create(user).Error
}

View file

@ -26,8 +26,9 @@ const (
// User represents a user account in the 3x-ui panel.
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Username string `json:"username" gorm:"uniqueIndex"`
Password string `json:"password"`
Role string `json:"role" gorm:"default:user"`
}
// Inbound represents an Xray inbound configuration with traffic statistics and settings.

View file

@ -3,6 +3,7 @@ package controller
import (
"fmt"
"net/http"
"strings"
"text/template"
"time"
@ -21,6 +22,13 @@ type LoginForm struct {
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
}
// RegisterForm represents the registration request structure.
type RegisterForm struct {
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
TurnstileToken string `json:"turnstileToken" form:"turnstileToken"`
}
// IndexController handles the main index and login-related routes.
type IndexController struct {
BaseController
@ -43,6 +51,7 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/logout", a.logout)
g.POST("/login", a.login)
g.POST("/register", a.register)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey)
}
@ -112,6 +121,52 @@ func (a *IndexController) login(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
}
// register handles new user registration.
func (a *IndexController) register(c *gin.Context) {
var form RegisterForm
if err := c.ShouldBind(&form); err != nil {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData"))
return
}
if form.Username == "" {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername"))
return
}
if form.Password == "" {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword"))
return
}
// Verify Turnstile token if site key is configured
turnstileSecretKey, err := a.settingService.GetTurnstileSecretKey()
if err == nil && turnstileSecretKey != "" {
if form.TurnstileToken == "" {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.turnstileRequired"))
return
}
if !service.VerifyTurnstile(turnstileSecretKey, form.TurnstileToken, getRemoteIp(c)) {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.turnstileRequired"))
return
}
}
err = a.userService.RegisterUser(form.Username, form.Password)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "already exists") {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.userExists"))
return
}
logger.Warningf("register failed for user \"%s\": %s", template.HTMLEscapeString(form.Username), err)
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.errorRegister"))
return
}
logger.Infof("new user registered: %s", template.HTMLEscapeString(form.Username))
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successRegister"), nil)
}
// logout handles user logout by clearing the session and redirecting to the login page.
func (a *IndexController) logout(c *gin.Context) {
user := session.GetLoginUser(c)

View file

@ -105,7 +105,8 @@ type AllSetting struct {
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
// Registration settings
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey" form:"turnstileSecretKey"`
}
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.

View file

@ -106,7 +106,190 @@ var defaultValueMap = map[string]string{
"ldapDefaultLimitIP": "0",
// Registration settings
"turnstileSiteKey": "",
"turnstileSiteKey": "",
"turnstileSecretKey": "",
}
// settingGroups defines the nested JSON structure for on-disk settings.
// Each group maps nested keys to their flat equivalents in defaultValueMap.
var settingGroups = map[string]map[string]string{
"web": {
"listen": "webListen",
"domain": "webDomain",
"port": "webPort",
"certFile": "webCertFile",
"keyFile": "webKeyFile",
"basePath": "webBasePath",
"sessionMaxAge": "sessionMaxAge",
},
"tgBot": {
"enable": "tgBotEnable",
"token": "tgBotToken",
"proxy": "tgBotProxy",
"apiServer": "tgBotAPIServer",
"chatId": "tgBotChatId",
"runTime": "tgRunTime",
"backup": "tgBotBackup",
"loginNotify": "tgBotLoginNotify",
"cpu": "tgCpu",
"lang": "tgLang",
},
"sub": {
"enable": "subEnable",
"jsonEnable": "subJsonEnable",
"title": "subTitle",
"supportUrl": "subSupportUrl",
"profileUrl": "subProfileUrl",
"announce": "subAnnounce",
"enableRouting": "subEnableRouting",
"routingRules": "subRoutingRules",
"listen": "subListen",
"port": "subPort",
"path": "subPath",
"domain": "subDomain",
"certFile": "subCertFile",
"keyFile": "subKeyFile",
"updates": "subUpdates",
"encrypt": "subEncrypt",
"showInfo": "subShowInfo",
"uri": "subURI",
"jsonPath": "subJsonPath",
"jsonURI": "subJsonURI",
"jsonFragment": "subJsonFragment",
"jsonNoises": "subJsonNoises",
"jsonMux": "subJsonMux",
"jsonRules": "subJsonRules",
},
"ldap": {
"enable": "ldapEnable",
"host": "ldapHost",
"port": "ldapPort",
"useTLS": "ldapUseTLS",
"bindDN": "ldapBindDN",
"password": "ldapPassword",
"baseDN": "ldapBaseDN",
"userFilter": "ldapUserFilter",
"userAttr": "ldapUserAttr",
"vlessField": "ldapVlessField",
"syncCron": "ldapSyncCron",
"flagField": "ldapFlagField",
"truthyValues": "ldapTruthyValues",
"invertFlag": "ldapInvertFlag",
"inboundTags": "ldapInboundTags",
"autoCreate": "ldapAutoCreate",
"autoDelete": "ldapAutoDelete",
"defaultTotalGB": "ldapDefaultTotalGB",
"defaultExpiryDays": "ldapDefaultExpiryDays",
"defaultLimitIP": "ldapDefaultLimitIP",
},
"other": {
"timeLocation": "timeLocation",
"twoFactorEnable": "twoFactorEnable",
"twoFactorToken": "twoFactorToken",
"externalTrafficInformEnable": "externalTrafficInformEnable",
"externalTrafficInformURI": "externalTrafficInformURI",
"turnstileSiteKey": "turnstileSiteKey",
"turnstileSecretKey": "turnstileSecretKey",
"datepicker": "datepicker",
"pageSize": "pageSize",
"expireDiff": "expireDiff",
"trafficDiff": "trafficDiff",
"remarkModel": "remarkModel",
"secret": "secret",
"warp": "warp",
"xrayOutboundTestUrl": "xrayOutboundTestUrl",
},
}
// flatToNestedKey maps flat keys to their [group, nestedKey] pair.
var flatToNestedKey map[string][2]string
func init() {
flatToNestedKey = make(map[string][2]string)
for group, keys := range settingGroups {
for nestedKey, flatKey := range keys {
flatToNestedKey[flatKey] = [2]string{group, nestedKey}
}
}
}
// expandToNested converts a flat map[string]string to nested map[string]any
// using the settingGroups mapping. Keys not in any group are placed at the top level.
func expandToNested(flat map[string]string) map[string]any {
result := make(map[string]any)
// Initialize all groups
for group := range settingGroups {
result[group] = make(map[string]string)
}
// Place each flat key into its group
for flatKey, value := range flat {
if pair, ok := flatToNestedKey[flatKey]; ok {
group, nestedKey := pair[0], pair[1]
result[group].(map[string]string)[nestedKey] = value
} else {
// Ungrouped keys go to top level
result[flatKey] = value
}
}
// Remove empty groups
for group := range result {
if m, ok := result[group].(map[string]string); ok && len(m) == 0 {
delete(result, group)
}
}
return result
}
// flattenNested converts a nested map[string]any (from JSON) to a flat map[string]string.
// It uses settingGroups to map nested keys back to flat keys.
func flattenNested(nested map[string]any) map[string]string {
result := make(map[string]string)
for key, val := range nested {
switch v := val.(type) {
case map[string]any:
// This is a group
if groupKeys, ok := settingGroups[key]; ok {
for nestedKey, flatKey := range groupKeys {
if strVal, exists := v[nestedKey]; exists {
result[flatKey] = fmt.Sprint(strVal)
}
}
}
default:
// Top-level value (ungrouped key)
result[key] = fmt.Sprint(val)
}
}
return result
}
// tryParseNested detects whether the JSON is nested or flat format and returns a flat map.
func tryParseNested(data []byte) (map[string]string, error) {
// First try to detect if it's nested by checking for object values
var probe map[string]any
if err := json.Unmarshal(data, &probe); err != nil {
return nil, err
}
// Check if any value is a nested object (map[string]any) — indicates nested format
for _, v := range probe {
if _, isNested := v.(map[string]any); isNested {
return flattenNested(probe), nil
}
}
// Flat format — all values are strings
result := make(map[string]string, len(probe))
for k, v := range probe {
result[k] = fmt.Sprint(v)
}
return result, nil
}
// loadSettings reads the JSON settings file into a map.
@ -128,8 +311,9 @@ func loadSettings() (map[string]string, error) {
if err != nil {
return nil, err
}
var settings map[string]string
if err := json.Unmarshal(data, &settings); err != nil {
// Detect format: try nested first, fall back to flat
settings, err := tryParseNested(data)
if err != nil {
return nil, fmt.Errorf("failed to parse settings file %s: %w", path, err)
}
// Merge missing keys from defaults so new fields are picked up on upgrade
@ -151,9 +335,10 @@ func loadSettings() (map[string]string, error) {
return settings, nil
}
// saveSettings writes the settings map to the JSON file.
// saveSettings writes the settings map to the JSON file in nested format.
func saveSettings(settings map[string]string) error {
data, err := json.MarshalIndent(settings, "", " ")
nested := expandToNested(settings)
data, err := json.MarshalIndent(nested, "", " ")
if err != nil {
return err
}
@ -770,6 +955,10 @@ func (s *SettingService) GetTurnstileSiteKey() (string, error) {
return s.getString("turnstileSiteKey")
}
func (s *SettingService) GetTurnstileSecretKey() (string, error) {
return s.getString("turnstileSecretKey")
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil {
return err

44
web/service/turnstile.go Normal file
View file

@ -0,0 +1,44 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/url"
"time"
)
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
type turnstileResponse struct {
Success bool `json:"success"`
}
// VerifyTurnstile verifies a Cloudflare Turnstile token with the given secret key.
func VerifyTurnstile(secretKey, token, remoteIP string) bool {
form := url.Values{
"secret": {secretKey},
"response": {token},
}
if remoteIP != "" {
form.Set("remoteip", remoteIP)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.PostForm(turnstileVerifyURL, form)
if err != nil {
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false
}
var result turnstileResponse
if err := json.Unmarshal(body, &result); err != nil {
return false
}
return result.Success
}

View file

@ -2,6 +2,7 @@ package service
import (
"errors"
"strings"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
@ -126,6 +127,35 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
Error
}
func (s *UserService) RegisterUser(username string, password string) error {
if username == "" {
return errors.New("username can not be empty")
}
if password == "" {
return errors.New("password can not be empty")
}
hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
if err != nil {
return err
}
db := database.GetDB()
user := &model.User{
Username: username,
Password: hashedPassword,
Role: "user",
}
if err := db.Create(user).Error; err != nil {
// Check for unique constraint violation
if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") {
return errors.New("username already exists")
}
return err
}
return nil
}
func (s *UserService) UpdateFirstUser(username string, password string) error {
if username == "" {
return errors.New("username can not be empty")

View file

@ -108,6 +108,9 @@
"emptyPassword" = "الباسورد مطلوب"
"wrongUsernameOrPassword" = "اسم المستخدم أو كلمة المرور أو كود المصادقة الثنائية غير صحيح."
"successLogin" = "لقد تم تسجيل الدخول إلى حسابك بنجاح."
"successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول."
"userExists" = "اسم المستخدم موجود بالفعل"
"errorRegister" = "فشل التسجيل"
[pages.index]
"title" = "نظرة عامة"

View file

@ -114,6 +114,8 @@
"wrongUsernameOrPassword" = "Invalid username or password or two-factor code."
"successLogin" = " You have successfully logged into your account."
"successRegister" = "Registration successful, please log in."
"userExists" = "Username already exists"
"errorRegister" = "Registration failed"
[pages.index]
"title" = "Overview"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "Por favor ingresa la contraseña."
"wrongUsernameOrPassword" = "Nombre de usuario, contraseña o código de dos factores incorrecto."
"successLogin" = "Has iniciado sesión en tu cuenta correctamente."
"successRegister" = "Registro exitoso, por favor inicia sesión."
"userExists" = "El nombre de usuario ya existe"
"errorRegister" = "Error en el registro"
[pages.index]
"title" = "Estado del Sistema"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "لطفا یک رمزعبور وارد کنید"
"wrongUsernameOrPassword" = "نام کاربری، رمز عبور یا کد دو مرحله‌ای نامعتبر است."
"successLogin" = "شما با موفقیت به حساب کاربری خود وارد شدید."
"successRegister" = "ثبت‌نام با موفقیت انجام شد، لطفاً وارد شوید."
"userExists" = "نام کاربری از قبل وجود دارد"
"errorRegister" = "ثبت نام ناموفق بود"
[pages.index]
"title" = "نمای کلی"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "Kata Sandi diperlukan"
"wrongUsernameOrPassword" = "Username, kata sandi, atau kode dua faktor tidak valid."
"successLogin" = "Anda telah berhasil masuk ke akun Anda."
"successRegister" = "Pendaftaran berhasil, silakan masuk."
"userExists" = "Nama pengguna sudah ada"
"errorRegister" = "Pendaftaran gagal"
[pages.index]
"title" = "Ikhtisar"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "パスワードを入力してください"
"wrongUsernameOrPassword" = "ユーザー名、パスワード、または二段階認証コードが無効です。"
"successLogin" = "アカウントに正常にログインしました。"
"successRegister" = "登録が完了しました。ログインしてください。"
"userExists" = "ユーザー名は既に存在します"
"errorRegister" = "登録に失敗しました"
[pages.index]
"title" = "システムステータス"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "Senha é obrigatória"
"wrongUsernameOrPassword" = "Nome de usuário, senha ou código de dois fatores inválido."
"successLogin" = "Você entrou na sua conta com sucesso."
"successRegister" = "Registro bem-sucedido, por favor faça login."
"userExists" = "Nome de usuário já existe"
"errorRegister" = "Falha no registro"
[pages.index]
"title" = "Visão Geral"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "Введите пароль"
"wrongUsernameOrPassword" = "Неверные данные учетной записи."
"successLogin" = "Вход выполнен успешно"
"successRegister" = "Регистрация прошла успешно, пожалуйста, войдите."
"userExists" = "Имя пользователя уже существует"
"errorRegister" = "Ошибка регистрации"
[pages.index]
"title" = "Дашборд"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "Şifre gerekli"
"wrongUsernameOrPassword" = "Geçersiz kullanıcı adı, şifre veya iki adımlı doğrulama kodu."
"successLogin" = "Hesabınıza başarıyla giriş yaptınız."
"successRegister" = "Kayıt başarılı, lütfen giriş yapın."
"userExists" = "Kullanıcı adı zaten mevcut"
"errorRegister" = "Kayıt başarısız"
[pages.index]
"title" = "Genel Bakış"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "Потрібен пароль"
"wrongUsernameOrPassword" = "Невірне ім’я користувача, пароль або код двофакторної аутентифікації."
"successLogin" = "Ви успішно увійшли до свого облікового запису."
"successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть."
"userExists" = "Ім'я користувача вже існує"
"errorRegister" = "Помилка реєстрації"
[pages.index]
"title" = "Огляд"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "Vui lòng nhập mật khẩu."
"wrongUsernameOrPassword" = "Tên người dùng, mật khẩu hoặc mã xác thực hai yếu tố không hợp lệ."
"successLogin" = "Bạn đã đăng nhập vào tài khoản thành công."
"successRegister" = "Đăng ký thành công, vui lòng đăng nhập."
"userExists" = "Tên người dùng đã tồn tại"
"errorRegister" = "Đăng ký thất bại"
[pages.index]
"title" = "Trạng thái hệ thống"

View file

@ -114,6 +114,8 @@
"wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。"
"successLogin" = "您已成功登录您的账户。"
"successRegister" = "注册成功,请登录。"
"userExists" = "用户名已存在"
"errorRegister" = "注册失败"
[pages.index]
"title" = "系统状态"

View file

@ -108,6 +108,9 @@
"emptyPassword" = "請輸入密碼"
"wrongUsernameOrPassword" = "用戶名、密碼或雙重驗證碼無效。"
"successLogin" = "您已成功登入您的帳戶。"
"successRegister" = "註冊成功,請登入。"
"userExists" = "使用者名稱已存在"
"errorRegister" = "註冊失敗"
[pages.index]
"title" = "系統狀態"