mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
2e14a2a51a
A failed login keeps its username in the in-memory log buffer, the limiter table and the notifier queue, and the route accepted any body the global cap allowed. The login route now takes at most 16 KiB and answers a username over 256 bytes with the generic failure before any of those see it; the user setters refuse such a name too, so no stored username can be one the login route turns away.
200 lines
6.6 KiB
Go
200 lines
6.6 KiB
Go
package controller
|
|
|
|
import (
|
|
"net/http"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// LoginForm represents the login request structure.
|
|
type LoginForm struct {
|
|
Username string `json:"username" form:"username"`
|
|
Password string `json:"password" form:"password"`
|
|
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
|
}
|
|
|
|
// IndexController handles the main index and login-related routes.
|
|
type IndexController struct {
|
|
BaseController
|
|
|
|
settingService service.SettingService
|
|
userService panel.UserService
|
|
panelService panel.PanelService
|
|
tgbot tgbot.Tgbot
|
|
}
|
|
|
|
// NewIndexController creates a new IndexController and initializes its routes.
|
|
func NewIndexController(g *gin.RouterGroup) *IndexController {
|
|
a := &IndexController{}
|
|
a.initRouter(g)
|
|
return a
|
|
}
|
|
|
|
// 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("/csrf-token", a.csrfToken)
|
|
g.GET("/sponsors", a.sponsors)
|
|
g.GET("/sponsors/logo/:name", a.sponsorLogo)
|
|
|
|
// The route carries three short fields; anything longer is refused unread.
|
|
g.POST("/login", middleware.MaxBodyBytes(16<<10), middleware.CSRFMiddleware(getRemoteIp), a.login)
|
|
g.POST("/logout", middleware.CSRFMiddleware(getRemoteIp), a.logout)
|
|
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(getRemoteIp), a.getTwoFactorEnable)
|
|
}
|
|
|
|
// sponsors is public so the login page can render its slot; failures stay silent.
|
|
func (a *IndexController) sponsors(c *gin.Context) {
|
|
list, err := a.panelService.GetSponsors()
|
|
if err != nil {
|
|
logger.Debug("sponsors fetch failed:", err)
|
|
c.JSON(http.StatusOK, entity.Msg{Success: false})
|
|
return
|
|
}
|
|
jsonObj(c, list, nil)
|
|
}
|
|
|
|
func (a *IndexController) sponsorLogo(c *gin.Context) {
|
|
data, contentType, err := a.panelService.GetSponsorLogo(c.Param("name"))
|
|
if err != nil {
|
|
logger.Debug("sponsor logo failed:", err)
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
c.Header("Cache-Control", "public, max-age=3600")
|
|
c.Data(http.StatusOK, contentType, data)
|
|
}
|
|
|
|
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
|
func (a *IndexController) index(c *gin.Context) {
|
|
if session.IsLogin(c) {
|
|
c.Header("Cache-Control", "no-store")
|
|
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/")
|
|
return
|
|
}
|
|
serveDistPage(c, "login.html")
|
|
}
|
|
|
|
// login handles user authentication and session creation.
|
|
func (a *IndexController) login(c *gin.Context) {
|
|
var form LoginForm
|
|
|
|
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
|
|
}
|
|
|
|
remoteIP := getRemoteIp(c)
|
|
// No stored name is longer; the log buffer, the limiter and the notifiers keep what follows.
|
|
if len(form.Username) > panel.MaxUsernameLength {
|
|
logger.Warningf("failed login: IP=%q, reason=%q", remoteIP, "username too long")
|
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
|
return
|
|
}
|
|
safeUser := template.HTMLEscapeString(form.Username)
|
|
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
|
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
|
|
reason := "too many failed attempts"
|
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", form.Username, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
|
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
|
Username: safeUser,
|
|
IP: remoteIP,
|
|
Time: timeStr,
|
|
Status: tgbot.LoginFail,
|
|
Reason: reason,
|
|
})
|
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
|
return
|
|
}
|
|
|
|
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
|
|
|
if user == nil {
|
|
reason := loginFailureReason(checkErr)
|
|
if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked {
|
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", form.Username, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
|
} else {
|
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q", form.Username, remoteIP, reason)
|
|
}
|
|
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
|
Username: safeUser,
|
|
IP: remoteIP,
|
|
Time: timeStr,
|
|
Status: tgbot.LoginFail,
|
|
Reason: reason,
|
|
})
|
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
|
return
|
|
}
|
|
|
|
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
|
|
logger.Infof("logged in successfully: username=%q, IP=%q", form.Username, remoteIP)
|
|
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
|
Username: safeUser,
|
|
IP: remoteIP,
|
|
Time: timeStr,
|
|
Status: tgbot.LoginSuccess,
|
|
})
|
|
|
|
if err := session.SetLoginUser(c, user); err != nil {
|
|
logger.Warning("Unable to save session:", err)
|
|
return
|
|
}
|
|
|
|
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
|
}
|
|
|
|
func loginFailureReason(err error) string {
|
|
if err != nil && err.Error() == "invalid 2fa code" {
|
|
return "invalid 2FA code"
|
|
}
|
|
return "invalid credentials"
|
|
}
|
|
|
|
func (a *IndexController) logout(c *gin.Context) {
|
|
user := session.GetLoginUser(c)
|
|
if user != nil {
|
|
logger.Infof("logged out successfully: username=%q", user.Username)
|
|
}
|
|
if err := session.ClearSession(c); err != nil {
|
|
logger.Warning("Unable to clear session on logout:", err)
|
|
}
|
|
c.Header("Cache-Control", "no-store")
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
// csrfToken returns the session CSRF token. Public — the login page
|
|
// needs a token before authenticating.
|
|
func (a *IndexController) csrfToken(c *gin.Context) {
|
|
token, err := session.EnsureCSRFToken(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "msg": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "obj": token})
|
|
}
|
|
|
|
// getTwoFactorEnable retrieves the current status of two-factor authentication.
|
|
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
|
|
status, err := a.settingService.GetTwoFactorEnable()
|
|
jsonObj(c, status, err)
|
|
}
|