mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 08:36:07 +00:00
feat(auth): block panel with default admin/admin credentials and guide credential change
checkLogin middleware now detects default admin/admin credentials and redirects every panel route to /panel/settings until they are changed. The settings page auto-opens the Authentication tab, shows a non-dismissible error banner, and lists 'Default credentials' first in the security checklist. Login response includes mustChangeCredentials so the login page can redirect directly. Logout is now POST-only. Password must be at least 10 characters and cannot be admin/admin.
This commit is contained in:
+34
-1
@@ -4,8 +4,10 @@ package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/session"
|
||||
|
||||
@@ -17,7 +19,8 @@ type BaseController struct{}
|
||||
|
||||
// checkLogin is a middleware that verifies user authentication and handles unauthorized access.
|
||||
func (a *BaseController) checkLogin(c *gin.Context) {
|
||||
if !session.IsLogin(c) {
|
||||
user := session.GetLoginUser(c)
|
||||
if user == nil {
|
||||
if isAjax(c) {
|
||||
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
|
||||
} else {
|
||||
@@ -25,11 +28,41 @@ func (a *BaseController) checkLogin(c *gin.Context) {
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if isDefaultAdminCredential(user.Username, user.Password) && !credentialChangeRouteAllowed(c) {
|
||||
if isAjax(c) {
|
||||
pureJsonMsg(c, http.StatusForbidden, false, "Change the default admin credentials before continuing.")
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/settings")
|
||||
}
|
||||
c.Abort()
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isDefaultAdminCredential(username string, hashedPassword string) bool {
|
||||
return username == "admin" && crypto.CheckPasswordHash(hashedPassword, "admin")
|
||||
}
|
||||
|
||||
func credentialChangeRouteAllowed(c *gin.Context) bool {
|
||||
basePath := c.GetString("base_path")
|
||||
path := c.Request.URL.Path
|
||||
allowedPrefixes := []string{
|
||||
basePath + "panel/settings",
|
||||
basePath + "panel/setting/",
|
||||
basePath + "panel/csrf-token",
|
||||
}
|
||||
for _, prefix := range allowedPrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// I18nWeb retrieves an internationalized message for the web interface based on the current locale.
|
||||
func I18nWeb(c *gin.Context, name string, params ...string) string {
|
||||
anyfunc, funcExists := c.Get("I18n")
|
||||
|
||||
@@ -57,11 +57,18 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
}
|
||||
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
||||
|
||||
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
nonceAttr := ""
|
||||
if nonce := c.GetString("csp_nonce"); nonce != "" {
|
||||
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
|
||||
}
|
||||
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
if name != "login.html" {
|
||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||
}
|
||||
if u := session.GetLoginUser(c); u != nil && isDefaultAdminCredential(u.Username, u.Password) {
|
||||
script += `;window.X_UI_MUST_CHANGE_CREDENTIALS=true`
|
||||
}
|
||||
script += `;</script>`
|
||||
inject := []byte(script)
|
||||
inject = append(inject, csrfMeta...)
|
||||
|
||||
+14
-2
@@ -39,7 +39,7 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
|
||||
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/", a.index)
|
||||
g.GET("/logout", a.logout)
|
||||
g.GET("/logout", a.logoutGet)
|
||||
// Public CSRF endpoint — the SPA login page (served by Vite in
|
||||
// dev or by serveDistPage in prod) needs a token to POST /login,
|
||||
// but the panel-side /panel/csrf-token sits behind checkLogin.
|
||||
@@ -48,6 +48,7 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/csrf-token", a.csrfToken)
|
||||
|
||||
g.POST("/login", middleware.CSRFMiddleware(), a.login)
|
||||
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
||||
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
||||
}
|
||||
|
||||
@@ -130,7 +131,9 @@ func (a *IndexController) login(c *gin.Context) {
|
||||
}
|
||||
|
||||
logger.Infof("%s logged in successfully", safeUser)
|
||||
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.login.toasts.successLogin"), gin.H{
|
||||
"mustChangeCredentials": user.Username == "admin" && form.Password == "admin",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func loginFailureReason(err error) string {
|
||||
@@ -150,9 +153,18 @@ func (a *IndexController) logout(c *gin.Context) {
|
||||
logger.Warning("Unable to clear session on logout:", err)
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
if isAjax(c) {
|
||||
jsonMsg(c, "", nil)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||
}
|
||||
|
||||
func (a *IndexController) logoutGet(c *gin.Context) {
|
||||
c.Header("Allow", http.MethodPost)
|
||||
c.AbortWithStatus(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// csrfToken returns the session CSRF token. Public — the login page
|
||||
// needs a token before authenticating.
|
||||
func (a *IndexController) csrfToken(c *gin.Context) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
@@ -20,6 +21,15 @@ type updateUserForm struct {
|
||||
NewPassword string `json:"newPassword" form:"newPassword"`
|
||||
}
|
||||
|
||||
type verifyTwoFactorForm struct {
|
||||
Code string `json:"code" form:"code"`
|
||||
}
|
||||
|
||||
type updateSecretForm struct {
|
||||
Key string `json:"key" form:"key"`
|
||||
Value string `json:"value" form:"value"`
|
||||
}
|
||||
|
||||
// SettingController handles settings and user management operations.
|
||||
type SettingController struct {
|
||||
settingService service.SettingService
|
||||
@@ -41,7 +51,9 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/all", a.getAllSetting)
|
||||
g.POST("/defaultSettings", a.getDefaultSettings)
|
||||
g.POST("/update", a.updateSetting)
|
||||
g.POST("/secret", a.updateSecret)
|
||||
g.POST("/updateUser", a.updateUser)
|
||||
g.POST("/verifyTwoFactor", a.verifyTwoFactor)
|
||||
g.POST("/restartPanel", a.restartPanel)
|
||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||
g.GET("/getApiToken", a.getApiToken)
|
||||
@@ -50,7 +62,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
// getAllSetting retrieves all current settings.
|
||||
func (a *SettingController) getAllSetting(c *gin.Context) {
|
||||
allSetting, err := a.settingService.GetAllSetting()
|
||||
allSetting, err := a.settingService.GetAllSettingView()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
@@ -80,6 +92,16 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
}
|
||||
|
||||
func (a *SettingController) updateSecret(c *gin.Context) {
|
||||
form := &updateSecretForm{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
err := a.settingService.UpdateSecret(form.Key, form.Value)
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
}
|
||||
|
||||
// updateUser updates the current user's username and password.
|
||||
func (a *SettingController) updateUser(c *gin.Context) {
|
||||
form := &updateUserForm{}
|
||||
@@ -93,10 +115,18 @@ func (a *SettingController) updateUser(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.originalUserPassIncorrect")))
|
||||
return
|
||||
}
|
||||
if form.NewUsername == "" || form.NewPassword == "" {
|
||||
if strings.TrimSpace(form.NewUsername) == "" || form.NewPassword == "" {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty")))
|
||||
return
|
||||
}
|
||||
if len(form.NewPassword) < 10 {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New("new password must be at least 10 characters"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(form.NewUsername) == "admin" && form.NewPassword == "admin" {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New("default admin/admin credentials are not allowed"))
|
||||
return
|
||||
}
|
||||
err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
|
||||
if err == nil {
|
||||
user.Username = form.NewUsername
|
||||
@@ -108,6 +138,19 @@ func (a *SettingController) updateUser(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
|
||||
}
|
||||
|
||||
func (a *SettingController) verifyTwoFactor(c *gin.Context) {
|
||||
form := &verifyTwoFactorForm{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
ok, err := a.userService.VerifyTwoFactorCode(form.Code)
|
||||
if err == nil && !ok {
|
||||
err = errors.New("invalid 2fa code")
|
||||
}
|
||||
jsonObj(c, ok, err)
|
||||
}
|
||||
|
||||
// restartPanel restarts the panel service after a delay.
|
||||
func (a *SettingController) restartPanel(c *gin.Context) {
|
||||
err := a.panelService.RestartPanel(time.Second * 3)
|
||||
|
||||
Reference in New Issue
Block a user