diff --git a/assets/lang/en.json b/assets/lang/en.json index b0cc355..14e3cb5 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -90,5 +90,13 @@ "Client Counts": "Client Counts", "Proxy Counts": "Proxy Counts", "Not Set": "Not Set", - "Proxy": "Proxies" + "Proxy": "Proxies", + "Username": "Username", + "Password": "Password", + "Login": "Login", + "Please input username": "Please input username", + "Please input password": "Please input password", + "Login success": "Login success", + "Username or password incorrect": "Username or password incorrect", + "Token invalid": "Token invalid" } \ No newline at end of file diff --git a/assets/lang/zh.json b/assets/lang/zh.json index 6440171..0b359be 100644 --- a/assets/lang/zh.json +++ b/assets/lang/zh.json @@ -90,5 +90,13 @@ "Client Counts": "客户端总数", "Proxy Counts": "代理总数", "Not Set": "未配置", - "Proxy": "代理数量" + "Proxy": "代理数量", + "Username": "用户名", + "Password": "密码", + "Login": "登录", + "Please input username": "请填写用户名", + "Please input password": "请填写密码", + "Login success": "登录成功", + "Username or password incorrect": "用户名或密码错误", + "Token invalid": "登录信息无效" } \ No newline at end of file diff --git a/assets/static/css/index-color.css b/assets/static/css/color.css similarity index 93% rename from assets/static/css/index-color.css rename to assets/static/css/color.css index ba48001..dcc7d9a 100644 --- a/assets/static/css/index-color.css +++ b/assets/static/css/color.css @@ -113,7 +113,17 @@ section.proxy-list .proxy-info .layui-row .layui-row > div:first-child { color: #99a9bf; } +.login-title, +.login-title a { + color: #333 !important; +} + @media (prefers-color-scheme: dark) { + .login-title, + .login-title a { + color: #99a9bf !important; + } + .layui-bg-blue { background-color: #395c74 !important; } @@ -154,6 +164,11 @@ section.proxy-list .proxy-info .layui-row .layui-row > div:first-child { border-color: #5f5f60; } + .layui-form-danger + .layui-form-select .layui-input, + .layui-form-danger:focus { + border-color: #ff5722 !important; + } + .layui-form-checkbox[lay-skin=primary]:hover > i { border-color: #5f5f60; } diff --git a/assets/static/css/index.css b/assets/static/css/index.css index 661b266..39e142a 100644 --- a/assets/static/css/index.css +++ b/assets/static/css/index.css @@ -47,6 +47,19 @@ section { height: 100%; padding: 0 15px; box-sizing: border-box; + display: flex; +} + +.layui-title #title { + flex: 1; + display: inline-block; +} + +.layui-title #logout{ + display: inline-block; + font-size: 20px; + font-weight: bold; + cursor: pointer; } .layui-nav.layui-nav-tree { diff --git a/assets/static/css/login.css b/assets/static/css/login.css new file mode 100644 index 0000000..ff9d37a --- /dev/null +++ b/assets/static/css/login.css @@ -0,0 +1,30 @@ +html, body { + width: 100%; + height: 100%; +} + +body { + display: flex; + flex-direction: column; + justify-content: center; +} + +.login-title { + text-align: center; + margin-bottom: 50px; +} + +.login-title .title-text { + font-size: 24px; + text-align: center; + font-weight: bold; +} + +.login-title .title-version { + font-size: 12px; +} + +.login-container { + width: 320px; + margin: 0 auto; +} \ No newline at end of file diff --git a/assets/static/js/index.js b/assets/static/js/index.js index 55b8dc5..351f299 100644 --- a/assets/static/js/index.js +++ b/assets/static/js/index.js @@ -1,26 +1,50 @@ var http_port, https_port; (function ($) { $(function () { - var langLoading = layui.layer.load() - $.getJSON('/lang.json').done(function (lang) { - layui.element.on('nav(leftNav)', function (elem) { - var id = elem.attr('id'); - var title = elem.text(); - if (id === 'serverInfo') { - loadServerInfo(lang, title.trim()); - } else if (id === 'userList') { - loadUserList(lang, title.trim()); - } else if (elem.closest('.layui-nav-item').attr('id') === 'proxyList') { - if (id != null && id.trim() !== '') { - var suffix = elem.closest('.layui-nav-item').children('a').text().trim(); - loadProxyInfo(lang, title + " " + suffix, id); - } - } - }); + function init() { + var langLoading = layui.layer.load() + $.getJSON('/lang.json').done(function (lang) { + $.ajaxSetup({ + error: function (xhr,) { + if (xhr.status === 401) { + layui.layer.msg(lang['TokenInvalid'], function () { + window.location.reload(); + }); + } + }, + }) - $('#leftNav .layui-this > a').click(); - }).always(function () { - layui.layer.close(langLoading); + layui.element.on('nav(leftNav)', function (elem) { + var id = elem.attr('id'); + var title = elem.text(); + if (id === 'serverInfo') { + loadServerInfo(lang, title.trim()); + } else if (id === 'userList') { + loadUserList(lang, title.trim()); + } else if (elem.closest('.layui-nav-item').attr('id') === 'proxyList') { + if (id != null && id.trim() !== '') { + var suffix = elem.closest('.layui-nav-item').children('a').text().trim(); + loadProxyInfo(lang, title + " " + suffix, id); + } + } + }); + + $('#leftNav .layui-this > a').click(); + }).always(function () { + layui.layer.close(langLoading); + }); + } + + function logout() { + $.get("/logout", function (result) { + window.location.reload(); + }); + } + + $(document).on('click.logout', '#logout', function () { + logout(); }); + + init(); }); })(layui.$); \ No newline at end of file diff --git a/assets/static/js/login.js b/assets/static/js/login.js new file mode 100644 index 0000000..2dfc2ea --- /dev/null +++ b/assets/static/js/login.js @@ -0,0 +1,34 @@ +(function ($) { + $(function () { + function login() { + if (!layui.form.validate('#loginForm')) { + return; + } + + $.ajax({ + url: "/login", + type: 'post', + data: { + username: $('#username').val(), + password: $('#password').val() + }, + success: function (result) { + if (result.success) { + document.cookie = 'token=' + result.token + ';path=/' + window.location.href = "/" + } else { + layui.layer.msg(result.message); + } + } + }); + } + + $(document).on('click.login', '#login', function () { + login(); + }).on('keydown', function (e) { + if (e.keyCode === 13) { + login(); + } + }); + }) +})(layui.$) \ No newline at end of file diff --git a/assets/templates/index.html b/assets/templates/index.html index 7cae6a5..fd23a47 100644 --- a/assets/templates/index.html +++ b/assets/templates/index.html @@ -5,7 +5,7 @@ - + @@ -27,7 +27,12 @@
-
+
+ + ${ if .showExit } + + ${ end } +
diff --git a/assets/templates/login.html b/assets/templates/login.html index 31a4b1b..6022f66 100644 --- a/assets/templates/login.html +++ b/assets/templates/login.html @@ -4,26 +4,26 @@ Login - - + + - - + - - - \ No newline at end of file diff --git a/cmd/frps-panel/cmd.go b/cmd/frps-panel/cmd.go index 6c7a966..96512a7 100644 --- a/cmd/frps-panel/cmd.go +++ b/cmd/frps-panel/cmd.go @@ -13,7 +13,7 @@ import ( "strings" ) -const version = "1.5.0" +const version = "1.6.0" var ( showVersion bool diff --git a/pkg/server/controller/authorizer.go b/pkg/server/controller/authorizer.go new file mode 100644 index 0000000..4dd873e --- /dev/null +++ b/pkg/server/controller/authorizer.go @@ -0,0 +1,68 @@ +package controller + +import ( + "encoding/base64" + "fmt" + "github.com/gin-gonic/gin" + "net/http" + "strings" +) + +func (c *HandleController) BasicAuth() gin.HandlerFunc { + return func(context *gin.Context) { + if strings.TrimSpace(c.CommonInfo.User) == "" || strings.TrimSpace(c.CommonInfo.Pwd) == "" { + ClearLogin(context) + if context.Request.RequestURI == LoginUrl { + context.Redirect(http.StatusTemporaryRedirect, LoginSuccessUrl) + } + return + } + + auth, err := context.Request.Cookie("token") + + if err == nil { + username, password, _ := ParseBasicAuth(auth.Value) + + usernameMatch := username == c.CommonInfo.User + passwordMatch := password == c.CommonInfo.Pwd + + if usernameMatch && passwordMatch { + context.Next() + return + } + } + + isAjax := context.GetHeader("X-Requested-With") == "XMLHttpRequest" + + if !isAjax && context.Request.RequestURI != LoginUrl { + context.Redirect(http.StatusTemporaryRedirect, LoginUrl) + } else { + context.AbortWithStatus(http.StatusUnauthorized) + } + } +} + +func ParseBasicAuth(auth string) (username, password string, ok bool) { + if len(auth) < len(AuthPrefix) || auth[:len(AuthPrefix)] != AuthPrefix { + return "", "", false + } + c, err := base64.StdEncoding.DecodeString(auth[len(AuthPrefix):]) + if err != nil { + return "", "", false + } + cs := string(c) + username, password, ok = strings.Cut(cs, ":") + if !ok { + return "", "", false + } + return username, password, true +} + +func EncodeBasicAuth(username, password string) string { + authString := fmt.Sprintf("%s:%s", username, password) + return AuthPrefix + base64.StdEncoding.EncodeToString([]byte(authString)) +} + +func ClearLogin(context *gin.Context) { + context.SetCookie("token", "", -1, "/", context.Request.Host, false, false) +} diff --git a/pkg/server/controller/controller.go b/pkg/server/controller/controller.go index 8aed894..8b59de9 100644 --- a/pkg/server/controller/controller.go +++ b/pkg/server/controller/controller.go @@ -12,105 +12,15 @@ import ( "io" "log" "net/http" - "regexp" "sort" "strconv" "strings" ) -const ( - Success = 0 - ParamError = 1 - UserExist = 2 - SaveError = 3 - UserFormatError = 4 - TokenFormatError = 5 - FrpServerError = 6 -) - -var UserFormatReg = regexp.MustCompile("^\\w+$") -var TokenFormatReg = regexp.MustCompile("^[\\w!@#$%^&*()]+$") -var TrimAllSpaceReg = regexp.MustCompile("[\\n\\t\\r\\s]") -var TrimBreakLineReg = regexp.MustCompile("[\\n\\t\\r]") - -type Response struct { - Msg string `json:"msg"` -} - -type HTTPError struct { - Code int - Err error -} - -type CommonInfo struct { - PluginAddr string - PluginPort int - User string - Pwd string - DashboardTLS bool - DashboardAddr string - DashboardPort int - DashboardUser string - DashboardPwd string -} - -type TokenInfo struct { - User string `json:"user" form:"user"` - Token string `json:"token" form:"token"` - Comment string `json:"comment" form:"comment"` - Ports string `json:"ports" from:"ports"` - Domains string `json:"domains" from:"domains"` - Subdomains string `json:"subdomains" from:"subdomains"` - Status bool `json:"status" form:"status"` -} - -type TokenResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Count int `json:"count"` - Data []TokenInfo `json:"data"` -} - -type OperationResponse struct { - Success bool `json:"success"` - Code int `json:"code"` - Message string `json:"message"` -} - -type ProxyResponse struct { - OperationResponse - Data string `json:"data"` -} - -type TokenSearch struct { - TokenInfo - Page int `form:"page"` - Limit int `form:"limit"` -} - -type TokenUpdate struct { - Before TokenInfo `json:"before"` - After TokenInfo `json:"after"` -} - -type TokenRemove struct { - Users []TokenInfo `json:"users"` -} - -type TokenDisable struct { - TokenRemove -} - -type TokenEnable struct { - TokenDisable -} - func (e *HTTPError) Error() string { return e.Err.Error() } -type HandlerFunc func(ctx *gin.Context) (interface{}, error) - func (c *HandleController) MakeHandlerFunc() gin.HandlerFunc { return func(context *gin.Context) { var response plugin.Response @@ -177,9 +87,48 @@ func (c *HandleController) MakeHandlerFunc() gin.HandlerFunc { func (c *HandleController) MakeLoginFunc() func(context *gin.Context) { return func(context *gin.Context) { - context.HTML(http.StatusOK, "login.html", gin.H{ - "version": c.Version, - }) + if context.Request.Method == "GET" { + if strings.TrimSpace(c.CommonInfo.User) == "" || strings.TrimSpace(c.CommonInfo.Pwd) == "" { + ClearLogin(context) + if context.Request.RequestURI == LoginUrl { + context.Redirect(http.StatusTemporaryRedirect, LoginSuccessUrl) + } + return + } + context.HTML(http.StatusOK, "login.html", gin.H{ + "version": c.Version, + "FrpsPanel": ginI18n.MustGetMessage(context, "Frps Panel"), + "Username": ginI18n.MustGetMessage(context, "Username"), + "Password": ginI18n.MustGetMessage(context, "Password"), + "Login": ginI18n.MustGetMessage(context, "Login"), + "PleaseInputUsername": ginI18n.MustGetMessage(context, "Please input username"), + "PleaseInputPassword": ginI18n.MustGetMessage(context, "Please input password"), + }) + } else if context.Request.Method == "POST" { + username := context.PostForm("username") + password := context.PostForm("password") + + auth := EncodeBasicAuth(username, password) + if auth == EncodeBasicAuth(c.CommonInfo.User, c.CommonInfo.Pwd) { + context.JSON(http.StatusOK, gin.H{ + "success": true, + "message": ginI18n.MustGetMessage(context, "Login success"), + "token": auth, + }) + } else { + context.JSON(http.StatusOK, gin.H{ + "success": false, + "message": ginI18n.MustGetMessage(context, "Username or password incorrect"), + "token": "", + }) + } + } + } +} + +func (c *HandleController) MakeLogoutFunc() func(context *gin.Context) { + return func(context *gin.Context) { + ClearLogin(context) } } @@ -187,6 +136,7 @@ func (c *HandleController) MakeIndexFunc() func(context *gin.Context) { return func(context *gin.Context) { context.HTML(http.StatusOK, "index.html", gin.H{ "version": c.Version, + "showExit": strings.TrimSpace(c.CommonInfo.User) != "" && strings.TrimSpace(c.CommonInfo.Pwd) != "", "FrpsPanel": ginI18n.MustGetMessage(context, "Frps Panel"), "User": ginI18n.MustGetMessage(context, "User"), "Token": ginI18n.MustGetMessage(context, "Token"), @@ -300,6 +250,7 @@ func (c *HandleController) MakeLangFunc() func(context *gin.Context) { "Proxies": ginI18n.MustGetMessage(context, "Proxies"), "NotSet": ginI18n.MustGetMessage(context, "Not Set"), "Proxy": ginI18n.MustGetMessage(context, "Proxy"), + "TokenInvalid": ginI18n.MustGetMessage(context, "Token invalid"), }) } } diff --git a/pkg/server/controller/op.go b/pkg/server/controller/handler.go similarity index 70% rename from pkg/server/controller/op.go rename to pkg/server/controller/handler.go index 5fe4a28..9e207fe 100644 --- a/pkg/server/controller/op.go +++ b/pkg/server/controller/handler.go @@ -3,86 +3,11 @@ package controller import ( "fmt" plugin "github.com/fatedier/frp/pkg/plugin/server" - "github.com/gin-gonic/gin" - "gopkg.in/ini.v1" "log" - "net/http" - "os" - "path/filepath" "strconv" "strings" ) -type HandleController struct { - CommonInfo CommonInfo - Tokens map[string]TokenInfo - Ports map[string][]string - Domains map[string][]string - Subdomains map[string][]string - ConfigFile string - IniFile *ini.File - Version string -} - -func NewHandleController(config *HandleController) *HandleController { - return config -} - -func (c *HandleController) Register(rootDir string, engine *gin.Engine) { - assets := filepath.Join(rootDir, "assets") - _, err := os.Stat(assets) - if err != nil && !os.IsExist(err) { - assets = "./assets" - } - - engine.Delims("${", "}") - engine.LoadHTMLGlob(filepath.Join(assets, "templates/*")) - engine.POST("/handler", c.MakeHandlerFunc()) - engine.Static("/static", filepath.Join(assets, "static")) - engine.GET("/login", c.MakeLoginFunc()) - engine.GET("/lang.json", c.MakeLangFunc()) - - var group *gin.RouterGroup - if len(c.CommonInfo.User) != 0 { - //group = engine.Group("/", gin.BasicAuthForRealm(gin.Accounts{ - // c.CommonInfo.User: c.CommonInfo.Pwd, - //}, "Restricted")) - group = engine.Group("/", c.BasicAuth()) - } else { - group = engine.Group("/") - } - group.POST("/login", c.MakeLoginFunc()) - group.GET("/", c.MakeIndexFunc()) - group.GET("/tokens", c.MakeQueryTokensFunc()) - group.POST("/add", c.MakeAddTokenFunc()) - group.POST("/update", c.MakeUpdateTokensFunc()) - group.POST("/remove", c.MakeRemoveTokensFunc()) - group.POST("/disable", c.MakeDisableTokensFunc()) - group.POST("/enable", c.MakeEnableTokensFunc()) - group.GET("/proxy/*serverApi", c.MakeProxyFunc()) -} - -func (c *HandleController) BasicAuth() gin.HandlerFunc { - return func(context *gin.Context) { - username, password, _ := context.Request.BasicAuth() - - usernameMatch := username == c.CommonInfo.User - passwordMatch := password == c.CommonInfo.Pwd - - if usernameMatch && passwordMatch { - context.Next() - return - } - - if context.Request.RequestURI == "/" { - context.Header("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`) - context.AbortWithStatus(http.StatusUnauthorized) - } else { - context.Redirect(http.StatusTemporaryRedirect, "/login") - } - } -} - func (c *HandleController) HandleLogin(content *plugin.LoginContent) plugin.Response { token := content.Metas["token"] user := content.User diff --git a/pkg/server/controller/register.go b/pkg/server/controller/register.go new file mode 100644 index 0000000..bfffb09 --- /dev/null +++ b/pkg/server/controller/register.go @@ -0,0 +1,58 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "gopkg.in/ini.v1" + "os" + "path/filepath" +) + +type HandleController struct { + CommonInfo CommonInfo + Tokens map[string]TokenInfo + Ports map[string][]string + Domains map[string][]string + Subdomains map[string][]string + ConfigFile string + IniFile *ini.File + Version string +} + +func NewHandleController(config *HandleController) *HandleController { + return config +} + +func (c *HandleController) Register(rootDir string, engine *gin.Engine) { + assets := filepath.Join(rootDir, "assets") + _, err := os.Stat(assets) + if err != nil && !os.IsExist(err) { + assets = "./assets" + } + + engine.Delims("${", "}") + engine.LoadHTMLGlob(filepath.Join(assets, "templates/*")) + engine.POST("/handler", c.MakeHandlerFunc()) + engine.Static("/static", filepath.Join(assets, "static")) + engine.GET("/lang.json", c.MakeLangFunc()) + engine.GET(LoginUrl, c.MakeLoginFunc()) + engine.POST(LoginUrl, c.MakeLoginFunc()) + engine.GET(LogoutUrl, c.MakeLogoutFunc()) + + var group *gin.RouterGroup + if len(c.CommonInfo.User) != 0 { + //group = engine.Group("/", gin.BasicAuthForRealm(gin.Accounts{ + // c.CommonInfo.User: c.CommonInfo.Pwd, + //}, "Restricted")) + group = engine.Group("/", c.BasicAuth()) + } else { + group = engine.Group("/") + } + group.GET("/", c.MakeIndexFunc()) + group.GET("/tokens", c.MakeQueryTokensFunc()) + group.POST("/add", c.MakeAddTokenFunc()) + group.POST("/update", c.MakeUpdateTokensFunc()) + group.POST("/remove", c.MakeRemoveTokensFunc()) + group.POST("/disable", c.MakeDisableTokensFunc()) + group.POST("/enable", c.MakeEnableTokensFunc()) + group.GET("/proxy/*serverApi", c.MakeProxyFunc()) +} diff --git a/pkg/server/controller/variables.go b/pkg/server/controller/variables.go new file mode 100644 index 0000000..458855c --- /dev/null +++ b/pkg/server/controller/variables.go @@ -0,0 +1,97 @@ +package controller + +import "regexp" + +const ( + Success = 0 + ParamError = 1 + UserExist = 2 + SaveError = 3 + UserFormatError = 4 + TokenFormatError = 5 + FrpServerError = 6 + + AuthPrefix = "Basic " + LoginUrl = "/login" + LogoutUrl = "/logout" + LoginSuccessUrl = "/" +) + +var ( + UserFormatReg = regexp.MustCompile("^\\w+$") + TokenFormatReg = regexp.MustCompile("^[\\w!@#$%^&*()]+$") + TrimAllSpaceReg = regexp.MustCompile("[\\n\\t\\r\\s]") + TrimBreakLineReg = regexp.MustCompile("[\\n\\t\\r]") +) + +type Response struct { + Msg string `json:"msg"` +} + +type HTTPError struct { + Code int + Err error +} + +type CommonInfo struct { + PluginAddr string + PluginPort int + User string + Pwd string + DashboardTLS bool + DashboardAddr string + DashboardPort int + DashboardUser string + DashboardPwd string +} + +type TokenInfo struct { + User string `json:"user" form:"user"` + Token string `json:"token" form:"token"` + Comment string `json:"comment" form:"comment"` + Ports string `json:"ports" from:"ports"` + Domains string `json:"domains" from:"domains"` + Subdomains string `json:"subdomains" from:"subdomains"` + Status bool `json:"status" form:"status"` +} + +type TokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Count int `json:"count"` + Data []TokenInfo `json:"data"` +} + +type OperationResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + Message string `json:"message"` +} + +type ProxyResponse struct { + OperationResponse + Data string `json:"data"` +} + +type TokenSearch struct { + TokenInfo + Page int `form:"page"` + Limit int `form:"limit"` +} + +type TokenUpdate struct { + Before TokenInfo `json:"before"` + After TokenInfo `json:"after"` +} + +type TokenRemove struct { + Users []TokenInfo `json:"users"` +} + +type TokenDisable struct { + TokenRemove +} + +type TokenEnable struct { + TokenDisable +}