mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 05:31:44 +08:00
feat: 实现登录验证码和安全入口错误页伪装功能 (#1206)
* Initial plan * feat: 实现登录验证码和安全入口错误页伪装功能 - 后端:添加登录验证码功能(密码错误3次后触发) - 后端:支持3种安全入口错误页伪装(418/nginx/close) - 后端:添加验证码API和更新设置项 - 前端:登录页支持验证码输入和刷新 - 前端:设置页添加登录验证码和错误页伪装选项 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: 修复代码审查问题 - hijack失败时回退到418错误页而非返回200 - 验证码输入去除空格 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 优化细节 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
1
go.mod
1
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
github.com/beevik/ntp v1.5.0
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/dchest/captcha v1.1.0
|
||||
github.com/expr-lang/expr v1.17.7
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/httplog/v3 v3.3.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -55,6 +55,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
||||
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
|
||||
@@ -248,27 +248,29 @@ func (r *settingRepo) GetPanel() (*request.SettingPanel, error) {
|
||||
}
|
||||
|
||||
return &request.SettingPanel{
|
||||
Name: name,
|
||||
Channel: channel,
|
||||
Locale: r.conf.App.Locale,
|
||||
Entrance: r.conf.HTTP.Entrance,
|
||||
OfflineMode: offlineMode,
|
||||
AutoUpdate: autoUpdate,
|
||||
Lifetime: r.conf.Session.Lifetime,
|
||||
IPHeader: r.conf.HTTP.IPHeader,
|
||||
BindDomain: r.conf.HTTP.BindDomain,
|
||||
BindIP: r.conf.HTTP.BindIP,
|
||||
BindUA: r.conf.HTTP.BindUA,
|
||||
WebsitePath: websitePath,
|
||||
BackupPath: backupPath,
|
||||
HiddenMenu: hiddenMenu,
|
||||
CustomLogo: customLogo,
|
||||
Port: r.conf.HTTP.Port,
|
||||
HTTPS: r.conf.HTTP.TLS,
|
||||
ACME: r.conf.HTTP.ACME,
|
||||
PublicIP: publicIP,
|
||||
Cert: crt,
|
||||
Key: key,
|
||||
Name: name,
|
||||
Channel: channel,
|
||||
Locale: r.conf.App.Locale,
|
||||
Entrance: r.conf.HTTP.Entrance,
|
||||
EntranceError: r.conf.HTTP.EntranceError,
|
||||
LoginCaptcha: r.conf.HTTP.LoginCaptcha,
|
||||
OfflineMode: offlineMode,
|
||||
AutoUpdate: autoUpdate,
|
||||
Lifetime: r.conf.Session.Lifetime,
|
||||
IPHeader: r.conf.HTTP.IPHeader,
|
||||
BindDomain: r.conf.HTTP.BindDomain,
|
||||
BindIP: r.conf.HTTP.BindIP,
|
||||
BindUA: r.conf.HTTP.BindUA,
|
||||
WebsitePath: websitePath,
|
||||
BackupPath: backupPath,
|
||||
HiddenMenu: hiddenMenu,
|
||||
CustomLogo: customLogo,
|
||||
Port: r.conf.HTTP.Port,
|
||||
HTTPS: r.conf.HTTP.TLS,
|
||||
ACME: r.conf.HTTP.ACME,
|
||||
PublicIP: publicIP,
|
||||
Cert: crt,
|
||||
Key: key,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -354,6 +356,8 @@ func (r *settingRepo) UpdatePanel(req *request.SettingPanel) (bool, error) {
|
||||
conf.App.Locale = req.Locale
|
||||
conf.HTTP.Port = req.Port
|
||||
conf.HTTP.Entrance = req.Entrance
|
||||
conf.HTTP.EntranceError = req.EntranceError
|
||||
conf.HTTP.LoginCaptcha = req.LoginCaptcha
|
||||
conf.HTTP.TLS = req.HTTPS
|
||||
conf.HTTP.ACME = req.ACME
|
||||
conf.HTTP.IPHeader = req.IPHeader
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/libtnb/sessions"
|
||||
|
||||
"github.com/acepanel/panel/pkg/config"
|
||||
"github.com/acepanel/panel/pkg/embed"
|
||||
"github.com/acepanel/panel/pkg/punycode"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,7 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
|
||||
}
|
||||
}
|
||||
if len(conf.HTTP.BindDomain) > 0 && !slices.Contains(conf.HTTP.BindDomain, host) {
|
||||
Abort(w, http.StatusTeapot, t.Get("invalid request domain: %s", r.Host))
|
||||
abortEntrance(w, r, conf, conf.App.Locale)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,12 +78,12 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
Abort(w, http.StatusTeapot, t.Get("invalid request ip: %s", ip))
|
||||
abortEntrance(w, r, conf, conf.App.Locale)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(conf.HTTP.BindUA) > 0 && !slices.Contains(conf.HTTP.BindUA, r.UserAgent()) {
|
||||
Abort(w, http.StatusTeapot, t.Get("invalid request user agent: %s", r.UserAgent()))
|
||||
abortEntrance(w, r, conf, conf.App.Locale)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,7 +123,7 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
|
||||
if !conf.App.Debug &&
|
||||
sess.Missing("verify_entrance") &&
|
||||
r.URL.Path != "/robots.txt" {
|
||||
Abort(w, http.StatusTeapot, t.Get("invalid access entrance"))
|
||||
abortEntrance(w, r, conf, conf.App.Locale)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,3 +131,43 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func abortEntrance(w http.ResponseWriter, r *http.Request, conf *config.Config, locale string) {
|
||||
errorType := conf.HTTP.EntranceError
|
||||
|
||||
switch errorType {
|
||||
case "close":
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if ok {
|
||||
conn, _, err := hj.Hijack()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 如果无法 hijack,则返回空响应
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
return
|
||||
case "nginx":
|
||||
content, err := embed.ErrorFS.ReadFile("error/nginx_404.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Server", "nginx")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
default:
|
||||
fileName := "error/418.html"
|
||||
if locale == "zh_CN" {
|
||||
fileName = "error/418_zh_CN.html"
|
||||
}
|
||||
content, _ := embed.ErrorFS.ReadFile(fileName)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func MustLogin(t *gotext.Locale, conf *config.Config, session *sessions.Manager,
|
||||
// 白名单
|
||||
whiteList := []string{
|
||||
"/api/user/key",
|
||||
"/api/user/captcha",
|
||||
"/api/user/login",
|
||||
"/api/user/logout",
|
||||
"/api/user/is_login",
|
||||
|
||||
@@ -3,28 +3,30 @@ package request
|
||||
import "net/http"
|
||||
|
||||
type SettingPanel struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Channel string `json:"channel" validate:"required|in:stable,beta"`
|
||||
Locale string `json:"locale" validate:"required"`
|
||||
Entrance string `json:"entrance" validate:"required"`
|
||||
OfflineMode bool `json:"offline_mode"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
TwoFA bool `json:"two_fa"`
|
||||
Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分
|
||||
IPHeader string `json:"ip_header"`
|
||||
BindDomain []string `json:"bind_domain"`
|
||||
BindIP []string `json:"bind_ip"`
|
||||
BindUA []string `json:"bind_ua"`
|
||||
WebsitePath string `json:"website_path" validate:"required"`
|
||||
BackupPath string `json:"backup_path" validate:"required"`
|
||||
HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项
|
||||
CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL
|
||||
Port uint `json:"port" validate:"required|min:1|max:65535"`
|
||||
HTTPS bool `json:"https"`
|
||||
ACME bool `json:"acme"`
|
||||
PublicIP []string `json:"public_ip"`
|
||||
Cert string `json:"cert" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Channel string `json:"channel" validate:"required|in:stable,beta"`
|
||||
Locale string `json:"locale" validate:"required"`
|
||||
Entrance string `json:"entrance" validate:"required"`
|
||||
EntranceError string `json:"entrance_error" validate:"in:418,nginx,close"` // 安全入口错误页伪装类型
|
||||
LoginCaptcha bool `json:"login_captcha"` // 登录验证码
|
||||
OfflineMode bool `json:"offline_mode"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
TwoFA bool `json:"two_fa"`
|
||||
Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分
|
||||
IPHeader string `json:"ip_header"`
|
||||
BindDomain []string `json:"bind_domain"`
|
||||
BindIP []string `json:"bind_ip"`
|
||||
BindUA []string `json:"bind_ua"`
|
||||
WebsitePath string `json:"website_path" validate:"required"`
|
||||
BackupPath string `json:"backup_path" validate:"required"`
|
||||
HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项
|
||||
CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL
|
||||
Port uint `json:"port" validate:"required|min:1|max:65535"`
|
||||
HTTPS bool `json:"https"`
|
||||
ACME bool `json:"acme"`
|
||||
PublicIP []string `json:"public_ip"`
|
||||
Cert string `json:"cert" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
}
|
||||
|
||||
func (r *SettingPanel) Rules(_ *http.Request) map[string]string {
|
||||
|
||||
@@ -5,10 +5,11 @@ type UserID struct {
|
||||
}
|
||||
|
||||
type UserLogin struct {
|
||||
Username string `json:"username" validate:"required"` // encrypted with RSA-OAEP
|
||||
Password string `json:"password" validate:"required"` // encrypted with RSA-OAEP
|
||||
SafeLogin bool `json:"safe_login"`
|
||||
PassCode string `json:"pass_code"`
|
||||
Username string `json:"username" validate:"required"` // encrypted with RSA-OAEP
|
||||
Password string `json:"password" validate:"required"` // encrypted with RSA-OAEP
|
||||
SafeLogin bool `json:"safe_login"`
|
||||
PassCode string `json:"pass_code"` // 2FA
|
||||
CaptchaCode string `json:"captcha_code"` // 验证码
|
||||
}
|
||||
|
||||
type UserIsTwoFA struct {
|
||||
|
||||
@@ -136,6 +136,7 @@ func (route *Http) Register(r *chi.Mux) {
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Route("/user", func(r chi.Router) {
|
||||
r.Get("/key", route.user.GetKey)
|
||||
r.Get("/captcha", route.user.GetCaptcha)
|
||||
r.With(middleware.Throttle(route.conf.HTTP.IPHeader, 5, time.Minute)).Post("/login", route.user.Login)
|
||||
r.Post("/logout", route.user.Logout)
|
||||
r.Get("/is_login", route.user.IsLogin)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/libtnb/chix"
|
||||
"github.com/libtnb/sessions"
|
||||
@@ -25,6 +26,9 @@ import (
|
||||
"github.com/acepanel/panel/pkg/rsacrypto"
|
||||
)
|
||||
|
||||
// 登录失败次数阈值,超过此次数需要验证码
|
||||
const loginFailThreshold = 3
|
||||
|
||||
type UserService struct {
|
||||
t *gotext.Locale
|
||||
conf *config.Config
|
||||
@@ -65,6 +69,37 @@ func (s *UserService) GetKey(w http.ResponseWriter, r *http.Request) {
|
||||
Success(w, pk)
|
||||
}
|
||||
|
||||
// GetCaptcha 获取登录验证码
|
||||
func (s *UserService) GetCaptcha(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := s.session.GetSession(r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
failCount := cast.ToInt(sess.Get("login_fail_count"))
|
||||
if !s.conf.HTTP.LoginCaptcha || failCount < loginFailThreshold {
|
||||
Success(w, chix.M{
|
||||
"required": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
captchaID := captcha.NewLen(4)
|
||||
sess.Put("captcha_id", captchaID)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := captcha.WriteImage(&buf, captchaID, 150, 50); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, chix.M{
|
||||
"required": true,
|
||||
"image": base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := s.session.GetSession(r)
|
||||
if err != nil {
|
||||
@@ -78,6 +113,16 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
failCount := cast.ToInt(sess.Get("login_fail_count"))
|
||||
if s.conf.HTTP.LoginCaptcha && failCount >= loginFailThreshold {
|
||||
captchaID, ok := sess.Get("captcha_id").(string)
|
||||
if !ok || captchaID == "" || !captcha.VerifyString(captchaID, req.CaptchaCode) {
|
||||
Error(w, http.StatusForbidden, s.t.Get("invalid captcha code"))
|
||||
return
|
||||
}
|
||||
sess.Forget("captcha_id")
|
||||
}
|
||||
|
||||
key, ok := sess.Get("key").(rsa.PrivateKey)
|
||||
if !ok {
|
||||
Error(w, http.StatusForbidden, s.t.Get("invalid key, please refresh the page"))
|
||||
@@ -88,6 +133,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
|
||||
decryptedPassword, _ := rsacrypto.DecryptData(&key, req.Password)
|
||||
user, err := s.userRepo.CheckPassword(string(decryptedUsername), string(decryptedPassword))
|
||||
if err != nil {
|
||||
sess.Put("login_fail_count", failCount+1)
|
||||
Error(w, http.StatusForbidden, "%v", err)
|
||||
return
|
||||
}
|
||||
@@ -128,6 +174,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
|
||||
sess.Put("user_id", user.ID)
|
||||
sess.Put("refresh_at", time.Now().Unix())
|
||||
sess.Forget("key")
|
||||
sess.Forget("login_fail_count")
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,6 @@ var WebsiteFS embed.FS
|
||||
|
||||
//go:embed all:locales/*
|
||||
var LocalesFS embed.FS
|
||||
|
||||
//go:embed all:error/*
|
||||
var ErrorFS embed.FS
|
||||
|
||||
17
pkg/embed/error/418.html
Normal file
17
pkg/embed/error/418.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>418 I'm a teapot</title>
|
||||
<style>body{background:#f2f3f5;margin:0;padding:20px;font-family:system-ui,sans-serif}.container{max-width:600px;margin:3em auto;background:#fff;padding:40px;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,.05)}h1{font-size:3em;font-weight:600;margin:0 0 30px;color:#1a1a1a}p{color:#5a5a5a;line-height:1.6}a{text-decoration:none;color:#333;font-weight:600}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>418 I'm a teapot</h1>
|
||||
<p>You are accessing AcePanel through the wrong entrance.</p>
|
||||
<p>If you have forgotten the entrance, please run <code>acepanel entrance off</code> to disable it.</p>
|
||||
<p><em>Powered by <a target="_blank" href="https://acepanel.net">AcePanel</a></em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
17
pkg/embed/error/418_zh_CN.html
Normal file
17
pkg/embed/error/418_zh_CN.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>418 I'm a teapot</title>
|
||||
<style>body{background:#f2f3f5;margin:0;padding:20px;font-family:system-ui,sans-serif}.container{max-width:600px;margin:3em auto;background:#fff;padding:40px;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,.05)}h1{font-size:3em;font-weight:600;margin:0 0 30px;color:#1a1a1a}p{color:#5a5a5a;line-height:1.6}a{text-decoration:none;color:#333;font-weight:600}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>418 I'm a teapot</h1>
|
||||
<p>您正在使用错误的入口访问 AcePanel。</p>
|
||||
<p>如果您忘记了入口,请运行 <code>acepanel entrance off</code> 将其关闭。</p>
|
||||
<p><em>由 <a target="_blank" href="https://acepanel.net">AcePanel</a> 强力驱动</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
7
pkg/embed/error/nginx_404.html
Normal file
7
pkg/embed/error/nginx_404.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<html>
|
||||
<head><title>404 Not Found</title></head>
|
||||
<body>
|
||||
<center><h1>404 Not Found</h1></center>
|
||||
<hr><center>nginx</center>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,13 +3,22 @@ import { http } from '@/utils'
|
||||
export default {
|
||||
// 公钥
|
||||
key: () => http.Get('/user/key'),
|
||||
// 获取验证码
|
||||
captcha: () => http.Get('/user/captcha'),
|
||||
// 登录
|
||||
login: (username: string, password: string, pass_code: string, safe_login: boolean) =>
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
pass_code: string,
|
||||
safe_login: boolean,
|
||||
captcha_code: string
|
||||
) =>
|
||||
http.Post('/user/login', {
|
||||
username,
|
||||
password,
|
||||
pass_code,
|
||||
safe_login
|
||||
safe_login,
|
||||
captcha_code
|
||||
}),
|
||||
// 登出
|
||||
logout: () => http.Post('/user/logout'),
|
||||
|
||||
@@ -20,13 +20,15 @@ interface LoginInfo {
|
||||
password: string
|
||||
safe_login: boolean
|
||||
pass_code: string
|
||||
captcha_code: string
|
||||
}
|
||||
|
||||
const loginInfo = ref<LoginInfo>({
|
||||
username: '',
|
||||
password: '',
|
||||
safe_login: true,
|
||||
pass_code: ''
|
||||
pass_code: '',
|
||||
captcha_code: ''
|
||||
})
|
||||
|
||||
const localLoginInfo = getLocal('loginInfo') as LoginInfo
|
||||
@@ -41,14 +43,42 @@ const logining = ref<boolean>(false)
|
||||
const isRemember = useStorage('isRemember', false)
|
||||
const showTwoFA = ref(false)
|
||||
|
||||
// 验证码相关
|
||||
const captchaRequired = ref(false)
|
||||
const captchaImage = ref('')
|
||||
|
||||
const logo = computed(() => themeStore.logo || logoImg)
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = () => {
|
||||
useRequest(user.captcha())
|
||||
.onSuccess(({ data }) => {
|
||||
captchaRequired.value = Boolean(data.required)
|
||||
captchaImage.value = data.image || ''
|
||||
loginInfo.value.captcha_code = ''
|
||||
})
|
||||
.onError(() => {
|
||||
captchaRequired.value = false
|
||||
captchaImage.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
// 初始加载验证码
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
const { username, password, pass_code, safe_login } = loginInfo.value
|
||||
const { username, password, pass_code, safe_login, captcha_code } = loginInfo.value
|
||||
if (!username || !password) {
|
||||
window.$message.warning($gettext('Please enter username and password'))
|
||||
return
|
||||
}
|
||||
const trimmedCaptcha = captcha_code?.trim() || ''
|
||||
if (captchaRequired.value && !trimmedCaptcha) {
|
||||
window.$message.warning($gettext('Please enter captcha code'))
|
||||
return
|
||||
}
|
||||
if (!key) {
|
||||
window.$message.warning(
|
||||
$gettext('Failed to get encryption public key, please refresh the page and try again')
|
||||
@@ -60,29 +90,35 @@ async function handleLogin() {
|
||||
rsaEncrypt(username, String(unref(key))),
|
||||
rsaEncrypt(password, String(unref(key))),
|
||||
pass_code,
|
||||
safe_login
|
||||
safe_login,
|
||||
trimmedCaptcha
|
||||
)
|
||||
).onSuccess(async () => {
|
||||
logining.value = true
|
||||
window.$notification?.success({ title: $gettext('Login successful!'), duration: 2500 })
|
||||
if (isRemember.value) {
|
||||
setLocal('loginInfo', { username, password })
|
||||
} else {
|
||||
removeLocal('loginInfo')
|
||||
}
|
||||
)
|
||||
.onSuccess(async () => {
|
||||
logining.value = true
|
||||
window.$notification?.success({ title: $gettext('Login successful!'), duration: 2500 })
|
||||
if (isRemember.value) {
|
||||
setLocal('loginInfo', { username, password })
|
||||
} else {
|
||||
removeLocal('loginInfo')
|
||||
}
|
||||
|
||||
await addDynamicRoutes()
|
||||
useRequest(user.info()).onSuccess(({ data }) => {
|
||||
userStore.set(data as any)
|
||||
await addDynamicRoutes()
|
||||
useRequest(user.info()).onSuccess(({ data }) => {
|
||||
userStore.set(data as any)
|
||||
})
|
||||
if (query.redirect) {
|
||||
const path = query.redirect as string
|
||||
Reflect.deleteProperty(query, 'redirect')
|
||||
await router.push({ path, query })
|
||||
} else {
|
||||
await router.push('/')
|
||||
}
|
||||
})
|
||||
.onError(() => {
|
||||
// 登录失败后刷新验证码状态
|
||||
refreshCaptcha()
|
||||
})
|
||||
if (query.redirect) {
|
||||
const path = query.redirect as string
|
||||
Reflect.deleteProperty(query, 'redirect')
|
||||
await router.push({ path, query })
|
||||
} else {
|
||||
await router.push('/')
|
||||
}
|
||||
})
|
||||
logining.value = false
|
||||
}
|
||||
|
||||
@@ -155,6 +191,26 @@ watch(isLogin, async () => {
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="captchaRequired" mt-30>
|
||||
<n-flex align="center">
|
||||
<n-input
|
||||
v-model:value="loginInfo.captcha_code"
|
||||
:maxlength="4"
|
||||
class="text-16 pl-10 h-50 items-center"
|
||||
style="flex: 1"
|
||||
:placeholder="$gettext('Captcha Code')"
|
||||
type="text"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
<n-image
|
||||
:src="'data:image/png;base64,' + captchaImage"
|
||||
preview-disabled
|
||||
class="cursor-pointer h-50"
|
||||
style="border-radius: 4px"
|
||||
@click="refreshCaptcha"
|
||||
/>
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<div mt-20>
|
||||
<n-flex>
|
||||
|
||||
@@ -26,6 +26,8 @@ const { data: model } = useRequest(setting.list, {
|
||||
locale: 'en',
|
||||
port: 8888,
|
||||
entrance: '',
|
||||
entrance_error: '418',
|
||||
login_captcha: false,
|
||||
offline_mode: false,
|
||||
two_fa: false,
|
||||
lifetime: 0,
|
||||
|
||||
@@ -29,12 +29,6 @@ const httpsMode = computed({
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const httpsModeOptions = computed(() => [
|
||||
{ label: $gettext('Disabled'), value: 'off' },
|
||||
{ label: $gettext('ACME (Auto)'), value: 'acme' },
|
||||
{ label: $gettext('Custom Certificate'), value: 'custom' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,6 +80,50 @@ const httpsModeOptions = computed(() => [
|
||||
</template>
|
||||
<n-input v-model:value="model.entrance" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<template #label>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<div class="flex items-center">
|
||||
{{ $gettext('Entrance Error Page') }}
|
||||
<the-icon :size="16" icon="mdi:help-circle-outline" class="ml-1" />
|
||||
</div>
|
||||
</template>
|
||||
{{
|
||||
$gettext(
|
||||
'Set the error page to display when accessing with wrong entrance. 418 shows teapot page, Nginx 404 shows nginx style 404 page, Close Connection will close the connection immediately'
|
||||
)
|
||||
}}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<n-select
|
||||
v-model:value="model.entrance_error"
|
||||
:options="[
|
||||
{ label: $gettext(`418 I'm a teapot`), value: '418' },
|
||||
{ label: $gettext('Nginx 404'), value: 'nginx' },
|
||||
{ label: $gettext('Close Connection'), value: 'close' }
|
||||
]"
|
||||
:placeholder="$gettext(`418 I'm a teapot`)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<template #label>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<div class="flex items-center">
|
||||
{{ $gettext('Login Captcha') }}
|
||||
<the-icon :size="16" icon="mdi:help-circle-outline" class="ml-1" />
|
||||
</div>
|
||||
</template>
|
||||
{{
|
||||
$gettext(
|
||||
'When enabled, a captcha will be required after 3 failed login attempts to prevent brute force attacks'
|
||||
)
|
||||
}}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<n-switch v-model:value="model.login_captcha" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<template #label>
|
||||
<n-tooltip>
|
||||
@@ -220,7 +258,11 @@ const httpsModeOptions = computed(() => [
|
||||
</template>
|
||||
<n-radio-group v-model:value="httpsMode">
|
||||
<n-radio-button
|
||||
v-for="option in httpsModeOptions"
|
||||
v-for="option in [
|
||||
{ label: $gettext('Disabled'), value: 'off' },
|
||||
{ label: $gettext('ACME (Auto)'), value: 'acme' },
|
||||
{ label: $gettext('Custom Certificate'), value: 'custom' }
|
||||
]"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
|
||||
Reference in New Issue
Block a user