feat: add support for passkey login method (#11601)

* feat: Add passkey authentication support with registration and login endpoints

* style: Refactor import statements and improve button formatting in login and settings views

* chore: Update dependencies in go.mod and go.sum, including adding go-webauthn and updating indirect dependencies

* refactor: Consolidate passkey session management and update related structures for improved clarity and functionality

* feat: Add passkey support in multiple languages with corresponding error messages and management options

* feat: Implement passkey reset command and enhance internationalization support for passkey-related messages

* refactor: Simplify login form structure by removing commented sections and unused passkey login function

* fix: Update passkey failure messages across multiple languages to include SSL certificate verification instructions

* chore: update go mod

* refactor: Update passkey handling by consolidating status checks and renaming related fields for clarity

* style: Format i18n file

* refactor: Enhance passkey configuration checks
This commit is contained in:
KOMATA authored and GitHub committed 2026-01-20 10:28:04 +08:00
1 parent 1025cd3535
commit d742c7d333
44 files changed
+1599 -55

No files matched your search

+73 -1
View File
@@ -2,7 +2,7 @@ package v2
import (
"encoding/base64"
"github.com/1Panel-dev/1Panel/core/utils/common"
"net/http"
"os"
"path"
@@ -12,6 +12,7 @@ import (
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/captcha"
"github.com/1Panel-dev/1Panel/core/utils/common"
"github.com/gin-gonic/gin"
)
@@ -100,6 +101,61 @@ func (b *BaseApi) MFALogin(c *gin.Context) {
helper.SuccessWithData(c, user)
}
// @Tags Auth
// @Summary User login with passkey
// @Success 200 {object} dto.PasskeyBeginResponse
// @Router /core/auth/passkey/begin [post]
func (b *BaseApi) PasskeyBeginLogin(c *gin.Context) {
entrance := loadEntranceFromRequest(c)
res, msgKey, err := authService.PasskeyBeginLogin(c, entrance)
if msgKey != "" {
if msgKey == "ErrEntrance" {
helper.BadAuth(c, msgKey, err)
return
}
if msgKey == "ErrPasskeyNotConfigured" {
helper.ErrorWithDetail(c, http.StatusNotFound, msgKey, err)
return
}
helper.ErrorWithDetail(c, http.StatusBadRequest, msgKey, err)
return
}
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags Auth
// @Summary User login with passkey
// @Success 200 {object} dto.UserLoginInfo
// @Router /core/auth/passkey/finish [post]
func (b *BaseApi) PasskeyFinishLogin(c *gin.Context) {
sessionID := c.GetHeader("Passkey-Session")
entrance := loadEntranceFromRequest(c)
user, msgKey, err := authService.PasskeyFinishLogin(c, sessionID, entrance)
go saveLoginLogs(c, err)
if msgKey == "ErrAuth" || msgKey == "ErrEntrance" {
if msgKey == "ErrAuth" {
global.IPTracker.SetNeedCaptcha(common.GetRealClientIP(c))
}
helper.BadAuth(c, msgKey, err)
return
}
if msgKey != "" {
helper.ErrorWithDetail(c, http.StatusBadRequest, msgKey, err)
return
}
if err != nil {
global.IPTracker.SetNeedCaptcha(common.GetRealClientIP(c))
helper.InternalServer(c, err)
return
}
global.IPTracker.Clear(common.GetRealClientIP(c))
helper.SuccessWithData(c, user)
}
// @Tags Auth
// @Summary User logout
// @Success 200
@@ -164,6 +220,7 @@ func (b *BaseApi) GetLoginSetting(c *gin.Context) {
Theme: settingInfo.Theme,
NeedCaptcha: needCaptcha,
}
res.PasskeySetting = authService.PasskeyStatus(c)
helper.SuccessWithData(c, res)
}
@@ -179,3 +236,18 @@ func saveLoginLogs(c *gin.Context, err error) {
logs.Agent = c.GetHeader("User-Agent")
_ = logService.CreateLoginLog(logs)
}
func loadEntranceFromRequest(c *gin.Context) string {
entranceItem := c.Request.Header.Get("EntranceCode")
var entrance []byte
if len(entranceItem) != 0 {
entrance, _ = base64.StdEncoding.DecodeString(entranceItem)
}
if len(entrance) == 0 {
cookieValue, err := c.Cookie("SecurityEntrance")
if err == nil {
entrance, _ = base64.StdEncoding.DecodeString(cookieValue)
}
}
return string(entrance)
}
+80
View File
@@ -420,6 +420,86 @@ func (b *BaseApi) MFABind(c *gin.Context) {
helper.Success(c)
}
// @Tags System Setting
// @Summary Begin passkey registration
// @Accept json
// @Param request body dto.PasskeyRegisterRequest true "request"
// @Success 200 {object} dto.PasskeyBeginResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/settings/passkey/register/begin [post]
func (b *BaseApi) PasskeyRegisterBegin(c *gin.Context) {
var req dto.PasskeyRegisterRequest
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, msgKey, err := authService.PasskeyBeginRegister(c, req.Name)
if msgKey != "" {
helper.ErrorWithDetail(c, http.StatusBadRequest, msgKey, err)
return
}
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags System Setting
// @Summary Finish passkey registration
// @Accept json
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/settings/passkey/register/finish [post]
func (b *BaseApi) PasskeyRegisterFinish(c *gin.Context) {
sessionID := c.GetHeader("Passkey-Session")
msgKey, err := authService.PasskeyFinishRegister(c, sessionID)
if msgKey != "" {
helper.ErrorWithDetail(c, http.StatusBadRequest, msgKey, err)
return
}
if err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags System Setting
// @Summary List passkeys
// @Success 200 {array} dto.PasskeyInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/settings/passkey/list [get]
func (b *BaseApi) PasskeyList(c *gin.Context) {
list, err := authService.PasskeyList()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags System Setting
// @Summary Delete passkey
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/settings/passkey/{id} [delete]
func (b *BaseApi) PasskeyDelete(c *gin.Context) {
id := c.Param("id")
if id == "" {
helper.BadRequest(c, errors.New("passkey id is required"))
return
}
if err := authService.PasskeyDelete(id); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) ReloadSSL(c *gin.Context) {
clientIP := c.ClientIP()
if clientIP != "127.0.0.1" {
+5
View File
@@ -11,6 +11,11 @@ type UserLoginInfo struct {
MfaStatus string `json:"mfaStatus"`
}
type PasskeyBeginResponse struct {
SessionID string `json:"sessionId"`
PublicKey interface{} `json:"publicKey"`
}
type MfaRequest struct {
Title string `json:"title" validate:"required"`
Interval int `json:"interval" validate:"required"`
+12
View File
@@ -250,4 +250,16 @@ type LoginSetting struct {
PanelName string `json:"panelName"`
Theme string `json:"theme"`
NeedCaptcha bool `json:"needCaptcha"`
PasskeySetting bool `json:"passkeySetting"`
}
type PasskeyRegisterRequest struct {
Name string `json:"name" validate:"required"`
}
type PasskeyInfo struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
LastUsedAt string `json:"lastUsedAt"`
}
+507 -1
View File
@@ -2,7 +2,15 @@ package service
import (
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/buserr"
@@ -10,8 +18,10 @@ import (
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
"github.com/1Panel-dev/1Panel/core/utils/mfa"
"github.com/1Panel-dev/1Panel/core/utils/passkey"
"github.com/gin-gonic/gin"
"strconv"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
type AuthService struct{}
@@ -22,6 +32,13 @@ type IAuthService interface {
Login(c *gin.Context, info dto.Login, entrance string) (*dto.UserLoginInfo, string, error)
LogOut(c *gin.Context) error
MFALogin(c *gin.Context, info dto.MFALogin, entrance string) (*dto.UserLoginInfo, string, error)
PasskeyBeginLogin(c *gin.Context, entrance string) (*dto.PasskeyBeginResponse, string, error)
PasskeyFinishLogin(c *gin.Context, sessionID, entrance string) (*dto.UserLoginInfo, string, error)
PasskeyBeginRegister(c *gin.Context, name string) (*dto.PasskeyBeginResponse, string, error)
PasskeyFinishRegister(c *gin.Context, sessionID string) (string, error)
PasskeyList() ([]dto.PasskeyInfo, error)
PasskeyDelete(id string) error
PasskeyStatus(c *gin.Context) bool
GetSecurityEntrance() string
IsLogin(c *gin.Context) bool
}
@@ -187,6 +204,495 @@ func (u *AuthService) IsLogin(c *gin.Context) bool {
return err == nil
}
func (u *AuthService) PasskeyStatus(c *gin.Context) bool {
enabled, err := u.passkeyEnabled(c)
if err != nil {
global.LOG.Errorf("passkey enabled check failed, err: %v", err)
enabled = false
}
configured, err := u.passkeyConfigured()
if err != nil {
global.LOG.Errorf("passkey config check failed, err: %v", err)
configured = false
}
return enabled && configured
}
func (u *AuthService) PasskeyBeginLogin(c *gin.Context, entrance string) (*dto.PasskeyBeginResponse, string, error) {
if err := u.checkEntrance(entrance); err != nil {
return nil, "ErrEntrance", err
}
config, msgKey, err := u.passkeyConfig(c)
if err != nil {
return nil, msgKey, err
}
records, err := loadPasskeyCredentialRecords()
if err != nil {
return nil, "", err
}
if len(records) == 0 {
return nil, "ErrPasskeyNotConfigured", buserr.New("ErrPasskeyNotConfigured")
}
user, err := u.passkeyUser(records, true)
if err != nil {
return nil, "", err
}
wa, err := webauthn.New(config)
if err != nil {
return nil, "", err
}
assertion, sessionData, err := wa.BeginLogin(user)
if err != nil {
return nil, "", err
}
passkeySessions := passkey.GetPasskeySessionStore()
sessionID := passkeySessions.Set(passkey.PasskeySessionKindLogin, "", *sessionData)
return &dto.PasskeyBeginResponse{SessionID: sessionID, PublicKey: assertion.Response}, "", nil
}
func (u *AuthService) PasskeyFinishLogin(c *gin.Context, sessionID, entrance string) (*dto.UserLoginInfo, string, error) {
if sessionID == "" {
return nil, "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
if err := u.checkEntrance(entrance); err != nil {
return nil, "ErrEntrance", err
}
config, msgKey, err := u.passkeyConfig(c)
if err != nil {
return nil, msgKey, err
}
passkeySessions := passkey.GetPasskeySessionStore()
session, ok := passkeySessions.Get(sessionID)
if !ok || session.Kind != passkey.PasskeySessionKindLogin {
return nil, "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
passkeySessions.Delete(sessionID)
records, err := loadPasskeyCredentialRecords()
if err != nil {
return nil, "", err
}
if len(records) == 0 {
return nil, "ErrPasskeyNotConfigured", buserr.New("ErrPasskeyNotConfigured")
}
user, err := u.passkeyUser(records, true)
if err != nil {
return nil, "", err
}
wa, err := webauthn.New(config)
if err != nil {
return nil, "", err
}
credential, err := wa.FinishLogin(user, session.Session, c.Request)
if err != nil {
return nil, "ErrAuth", err
}
if err := updatePasskeyCredentialRecord(records, credential); err != nil {
return nil, "ErrAuth", err
}
if err := savePasskeyCredentialRecords(records); err != nil {
return nil, "", err
}
userSetting, err := settingRepo.Get(repo.WithByKey("UserName"))
if err != nil {
return nil, "", err
}
res, err := u.generateSession(c, userSetting.Value)
if err != nil {
return nil, "", err
}
if entrance != "" {
entranceValue := base64.StdEncoding.EncodeToString([]byte(entrance))
c.SetCookie("SecurityEntrance", entranceValue, 0, "", "", false, true)
}
return res, "", nil
}
func (u *AuthService) PasskeyBeginRegister(c *gin.Context, name string) (*dto.PasskeyBeginResponse, string, error) {
config, msgKey, err := u.passkeyConfig(c)
if err != nil {
return nil, msgKey, err
}
records, err := loadPasskeyCredentialRecords()
if err != nil {
return nil, "", err
}
if len(records) >= passkey.PasskeyMaxCredentials {
return nil, "ErrPasskeyLimit", buserr.New("ErrPasskeyLimit")
}
user, err := u.passkeyUser(records, true)
if err != nil {
return nil, "", err
}
wa, err := webauthn.New(config)
if err != nil {
return nil, "", err
}
exclusions := make([]protocol.CredentialDescriptor, len(user.Credentials))
for i, credential := range user.Credentials {
exclusions[i] = credential.Descriptor()
}
creation, sessionData, err := wa.BeginRegistration(user, webauthn.WithExclusions(exclusions))
if err != nil {
return nil, "", err
}
passkeySessions := passkey.GetPasskeySessionStore()
sessionID := passkeySessions.Set(passkey.PasskeySessionKindRegister, strings.TrimSpace(name), *sessionData)
return &dto.PasskeyBeginResponse{SessionID: sessionID, PublicKey: creation.Response}, "", nil
}
func (u *AuthService) PasskeyFinishRegister(c *gin.Context, sessionID string) (string, error) {
if sessionID == "" {
return "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
config, msgKey, err := u.passkeyConfig(c)
if err != nil {
return msgKey, err
}
passkeySessions := passkey.GetPasskeySessionStore()
session, ok := passkeySessions.Get(sessionID)
if !ok || session.Kind != passkey.PasskeySessionKindRegister {
return "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
passkeySessions.Delete(sessionID)
records, err := loadPasskeyCredentialRecords()
if err != nil {
return "", err
}
if len(records) >= passkey.PasskeyMaxCredentials {
return "ErrPasskeyLimit", buserr.New("ErrPasskeyLimit")
}
user, err := u.passkeyUser(records, true)
if err != nil {
return "", err
}
wa, err := webauthn.New(config)
if err != nil {
return "", err
}
credential, err := wa.FinishRegistration(user, session.Session, c.Request)
if err != nil {
return "ErrPasskeyVerify", err
}
if passkeyCredentialExists(records, credential.ID) {
return "ErrPasskeyDuplicate", buserr.New("ErrPasskeyDuplicate")
}
displayName := strings.TrimSpace(session.Name)
if displayName == "" {
displayName = fmt.Sprintf("%s-%s", passkey.PasskeyCredentialNameDefault, time.Now().Format("20060102150405"))
}
records = append(records, passkey.PasskeyCredentialRecord{
ID: base64.RawURLEncoding.EncodeToString(credential.ID),
Name: displayName,
CreatedAt: time.Now().Format(constant.DateTimeLayout),
LastUsedAt: "",
FlagsValue: credentialFlagsValue(credential.Flags),
Credential: *credential,
})
if err := savePasskeyCredentialRecords(records); err != nil {
return "", err
}
return "", nil
}
func (u *AuthService) PasskeyList() ([]dto.PasskeyInfo, error) {
records, err := loadPasskeyCredentialRecords()
if err != nil {
return nil, err
}
list := make([]dto.PasskeyInfo, 0, len(records))
for _, record := range records {
list = append(list, dto.PasskeyInfo{
ID: record.ID,
Name: record.Name,
CreatedAt: record.CreatedAt,
LastUsedAt: record.LastUsedAt,
})
}
return list, nil
}
func (u *AuthService) PasskeyDelete(id string) error {
records, err := loadPasskeyCredentialRecords()
if err != nil {
return err
}
index := -1
for i, record := range records {
if record.ID == id {
index = i
break
}
}
if index == -1 {
return buserr.New("ErrRecordNotFound")
}
records = append(records[:index], records[index+1:]...)
return savePasskeyCredentialRecords(records)
}
func (u *AuthService) passkeyEnabled(c *gin.Context) (bool, error) {
sslSetting, err := settingRepo.Get(repo.WithByKey("SSL"))
if err != nil {
return false, err
}
if sslSetting.Value == constant.StatusDisable {
return false, nil
}
return strings.EqualFold(passkeyRequestScheme(c), "https"), nil
}
func (u *AuthService) passkeyConfigured() (bool, error) {
bindDomain, err := settingRepo.Get(repo.WithByKey("BindDomain"))
if err != nil {
return false, err
}
if strings.TrimSpace(bindDomain.Value) == "" {
return false, nil
}
records, err := loadPasskeyCredentialRecords()
if err != nil {
return false, err
}
return len(records) > 0, nil
}
func (u *AuthService) passkeyUser(records []passkey.PasskeyCredentialRecord, allowCreate bool) (*passkey.PasskeyUser, error) {
userID, err := u.passkeyUserID(allowCreate)
if err != nil {
return nil, err
}
nameSetting, err := settingRepo.Get(repo.WithByKey("UserName"))
if err != nil {
return nil, err
}
credentials := make([]webauthn.Credential, len(records))
for i, record := range records {
credentials[i] = record.Credential
}
return &passkey.PasskeyUser{
ID: userID,
Name: nameSetting.Value,
DisplayName: nameSetting.Value,
Credentials: credentials,
}, nil
}
func (u *AuthService) passkeyUserID(allowCreate bool) ([]byte, error) {
setting, err := settingRepo.Get(repo.WithByKey(passkey.PasskeyUserIDSettingKey))
if err != nil {
return nil, err
}
if setting.Value == "" {
if !allowCreate {
return nil, buserr.New("ErrPasskeyNotConfigured")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
encoded := base64.RawURLEncoding.EncodeToString(raw)
if err := settingRepo.Update(passkey.PasskeyUserIDSettingKey, encoded); err != nil {
return nil, err
}
return raw, nil
}
raw, err := base64.RawURLEncoding.DecodeString(setting.Value)
if err != nil {
return nil, err
}
return raw, nil
}
func (u *AuthService) passkeyConfig(c *gin.Context) (*webauthn.Config, string, error) {
enabled, err := u.passkeyEnabled(c)
if err != nil {
return nil, "", err
}
if !enabled {
return nil, "ErrPasskeyDisabled", buserr.New("ErrPasskeyDisabled")
}
origin, rpID, err := passkeyOriginAndRPID(c)
if err != nil {
return nil, "", err
}
panelName, err := settingRepo.Get(repo.WithByKey("PanelName"))
if err != nil {
return nil, "", err
}
return &webauthn.Config{
RPID: rpID,
RPDisplayName: panelName.Value,
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
},
}, "", nil
}
func (u *AuthService) checkEntrance(entrance string) error {
entranceSetting, err := settingRepo.Get(repo.WithByKey("SecurityEntrance"))
if err != nil {
return err
}
if len(entranceSetting.Value) != 0 && entranceSetting.Value != entrance {
return buserr.New("ErrEntrance")
}
return nil
}
func loadPasskeyCredentialRecords() ([]passkey.PasskeyCredentialRecord, error) {
setting, err := settingRepo.Get(repo.WithByKey(passkey.PasskeyCredentialSettingKey))
if err != nil {
return nil, err
}
if setting.Value == "" {
return []passkey.PasskeyCredentialRecord{}, nil
}
decrypted, err := encrypt.StringDecrypt(setting.Value)
if err != nil {
return nil, err
}
var records []passkey.PasskeyCredentialRecord
if err := json.Unmarshal([]byte(decrypted), &records); err != nil {
return nil, err
}
for i := range records {
records[i].Credential.Flags = webauthn.NewCredentialFlags(protocol.AuthenticatorFlags(records[i].FlagsValue))
}
return records, nil
}
func savePasskeyCredentialRecords(records []passkey.PasskeyCredentialRecord) error {
if len(records) == 0 {
return settingRepo.Update(passkey.PasskeyCredentialSettingKey, "")
}
for i := range records {
records[i].FlagsValue = credentialFlagsValue(records[i].Credential.Flags)
}
raw, err := json.Marshal(records)
if err != nil {
return err
}
encrypted, err := encrypt.StringEncrypt(string(raw))
if err != nil {
return err
}
return settingRepo.Update(passkey.PasskeyCredentialSettingKey, encrypted)
}
func passkeyCredentialExists(records []passkey.PasskeyCredentialRecord, credentialID []byte) bool {
encoded := base64.RawURLEncoding.EncodeToString(credentialID)
for _, record := range records {
if record.ID == encoded {
return true
}
}
return false
}
func updatePasskeyCredentialRecord(records []passkey.PasskeyCredentialRecord, credential *webauthn.Credential) error {
encoded := base64.RawURLEncoding.EncodeToString(credential.ID)
for i := range records {
if records[i].ID == encoded {
records[i].Credential = *credential
records[i].FlagsValue = credentialFlagsValue(credential.Flags)
records[i].LastUsedAt = time.Now().Format(constant.DateTimeLayout)
return nil
}
}
return buserr.New("ErrPasskeyNotConfigured")
}
func credentialFlagsValue(flags webauthn.CredentialFlags) uint8 {
var value protocol.AuthenticatorFlags
if flags.UserPresent {
value |= protocol.FlagUserPresent
}
if flags.UserVerified {
value |= protocol.FlagUserVerified
}
if flags.BackupEligible {
value |= protocol.FlagBackupEligible
}
if flags.BackupState {
value |= protocol.FlagBackupState
}
return uint8(value)
}
func passkeyOriginAndRPID(c *gin.Context) (string, string, error) {
host := passkeyRequestHost(c)
if host == "" {
return "", "", fmt.Errorf("missing request host")
}
scheme := passkeyRequestScheme(c)
origin := fmt.Sprintf("%s://%s", scheme, host)
bindDomain, err := settingRepo.Get(repo.WithByKey("BindDomain"))
if err != nil {
return "", "", err
}
bindDomainValue := strings.TrimSpace(bindDomain.Value)
if bindDomainValue == "" {
return "", "", buserr.New("ErrPasskeyNotConfigured")
}
hostDomain := stripHostPort(host)
bindDomainValue = stripHostPort(bindDomainValue)
if hostDomain == "" || !strings.EqualFold(hostDomain, bindDomainValue) {
return "", "", buserr.New("ErrPasskeyDisabled")
}
return origin, bindDomainValue, nil
}
func passkeyRequestHost(c *gin.Context) string {
host := c.Request.Host
if strings.Contains(host, ",") {
host = strings.TrimSpace(strings.Split(host, ",")[0])
}
return strings.TrimSpace(host)
}
func passkeyRequestScheme(c *gin.Context) string {
if c.Request.TLS != nil {
return "https"
}
return "http"
}
func stripHostPort(hostport string) string {
if hostport == "" {
return hostport
}
hostport = strings.TrimSpace(hostport)
if host, _, err := net.SplitHostPort(hostport); err == nil {
return strings.Trim(host, "[]")
}
return strings.Trim(hostport, "[]")
}
func checkPassword(password string) error {
priKey, _ := settingRepo.Get(repo.WithByKey("PASSWORD_PRIVATE_KEY"))
+23
View File
@@ -31,6 +31,7 @@ import (
"github.com/1Panel-dev/1Panel/core/utils/controller"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
"github.com/1Panel-dev/1Panel/core/utils/firewall"
"github.com/1Panel-dev/1Panel/core/utils/passkey"
"github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local"
"github.com/1Panel-dev/1Panel/core/utils/xpack"
"github.com/gin-gonic/gin"
@@ -146,6 +147,9 @@ func (u *SettingService) Update(key, value string) error {
if len(value) != 0 {
_ = global.SESSION.Clean()
}
if err := u.clearPasskeySettings(); err != nil {
return err
}
case "UserName", "Password":
_ = global.SESSION.Clean()
case "Language":
@@ -256,6 +260,9 @@ func (u *SettingService) UpdatePort(port uint) error {
if err := settingRepo.Update("ServerPort", strconv.Itoa(int(port))); err != nil {
return err
}
if err := u.clearPasskeySettings(); err != nil {
return err
}
go func() {
time.Sleep(1 * time.Second)
controller.RestartPanel(true, false, false)
@@ -273,6 +280,9 @@ func (u *SettingService) UpdateSSL(c *gin.Context, req dto.SSLUpdate) error {
if err := settingRepo.Update("SSLType", "self"); err != nil {
return err
}
if err := u.clearPasskeySettings(); err != nil {
return err
}
_ = os.Remove(path.Join(secretDir, "server.crt"))
_ = os.Remove(path.Join(secretDir, "server.key"))
go func() {
@@ -378,6 +388,9 @@ func (u *SettingService) UpdateSSL(c *gin.Context, req dto.SSLUpdate) error {
if err := settingRepo.Update("SSL", req.SSL); err != nil {
return err
}
if err := u.clearPasskeySettings(); err != nil {
return err
}
return u.UpdateSystemSSL()
}
@@ -516,6 +529,16 @@ func (u *SettingService) UpdatePassword(c *gin.Context, old, new string) error {
return nil
}
func (u *SettingService) clearPasskeySettings() error {
if err := settingRepo.Update(passkey.PasskeyUserIDSettingKey, ""); err != nil {
return err
}
if err := settingRepo.Update(passkey.PasskeyCredentialSettingKey, ""); err != nil {
return err
}
return nil
}
func (u *SettingService) UpdateSystemSSL() error {
certPath := path.Join(global.CONF.Base.InstallDir, "1panel/secret/server.crt")
keyPath := path.Join(global.CONF.Base.InstallDir, "1panel/secret/server.key")
+22
View File
@@ -5,6 +5,7 @@ import (
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/i18n"
"github.com/1Panel-dev/1Panel/core/utils/passkey"
"github.com/spf13/cobra"
)
@@ -20,6 +21,7 @@ func init() {
resetCmd.AddCommand(resetEntranceCmd)
resetCmd.AddCommand(resetBindIpsCmd)
resetCmd.AddCommand(resetDomainCmd)
resetCmd.AddCommand(resetPasskeyCmd)
}
var resetCmd = &cobra.Command{
@@ -112,6 +114,25 @@ var resetDomainCmd = &cobra.Command{
},
}
var resetPasskeyCmd = &cobra.Command{
Use: "passkey",
RunE: func(cmd *cobra.Command, args []string) error {
i18n.UseI18nForCmd(language)
if !isRoot() {
fmt.Println(i18n.GetMsgWithMapForCmd("SudoHelper", map[string]interface{}{"cmd": "sudo 1pctl reset passkey"}))
return nil
}
db, err := loadDBConn("core.db")
if err != nil {
return err
}
if err := setSettingByKey(db, passkey.PasskeyUserIDSettingKey, ""); err != nil {
return err
}
return setSettingByKey(db, passkey.PasskeyCredentialSettingKey, "")
},
}
func loadResetHelper() {
fmt.Println(i18n.GetMsgByKeyForCmd("ResetCommands"))
fmt.Println("\nUsage:\n 1panel reset [command]\n\nAvailable Commands:")
@@ -120,6 +141,7 @@ func loadResetHelper() {
fmt.Println(" https " + i18n.GetMsgByKeyForCmd("ResetHttps"))
fmt.Println(" ips " + i18n.GetMsgByKeyForCmd("ResetIPs"))
fmt.Println(" mfa " + i18n.GetMsgByKeyForCmd("ResetMFA"))
fmt.Println(" passkey " + i18n.GetMsgByKeyForCmd("ResetPasskey"))
fmt.Println("\nFlags:\n -h, --help help for reset")
fmt.Println("\nUse \"1panel reset [command] --help\" for more information about a command.")
}
+8 -2
View File
@@ -12,6 +12,7 @@ require (
github.com/go-gormigrate/gormigrate/v2 v2.1.2
github.com/go-playground/validator/v10 v10.22.0
github.com/go-resty/resty/v2 v2.15.3
github.com/go-webauthn/webauthn v0.15.0
github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
@@ -25,6 +26,7 @@ require (
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/soheilhy/cmux v0.1.5
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/swaggo/files/v2 v2.0.2
@@ -50,6 +52,7 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
@@ -59,8 +62,12 @@ require (
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@@ -68,7 +75,6 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
@@ -81,7 +87,6 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/soheilhy/cmux v0.1.5 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
@@ -89,6 +94,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
+18 -4
View File
@@ -36,6 +36,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
@@ -75,13 +77,23 @@ github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWa
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -168,7 +180,6 @@ github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZY
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -214,14 +225,12 @@ github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7g
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -280,8 +289,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
@@ -294,6 +304,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wader/gormstore/v2 v2.0.3 h1:/29GWPauY8xZkpLnB8hsp+dZfP3ivA9fiDw1YVNTp6U=
github.com/wader/gormstore/v2 v2.0.3/go.mod h1:sr3N3a8F1+PBc3fHoKaphFqDXLRJ9Oe6Yow0HxKFbbg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -304,6 +316,8 @@ go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
+8 -1
View File
@@ -14,6 +14,12 @@ ErrApiConfigKeyInvalid: "API key error: {{ .detail }}"
ErrApiConfigIPInvalid: "The API request IP is not on the whitelist: {{ .detail }}"
ErrApiConfigDisable: "This interface prohibits API calls: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "API timestamp error: {{ .detail }}"
ErrPasskeyDisabled: "Passkey requires HTTPS to be enabled"
ErrPasskeyNotConfigured: "No passkey configured"
ErrPasskeyLimit: "Passkey limit reached (max 5)"
ErrPasskeySession: "Passkey session expired or invalid"
ErrPasskeyDuplicate: "Passkey already exists"
ErrPasskeyVerify: "Passkey verification failed"
# request
ErrNoSuchHost: "Unable to find the requested server {{ .err }}"
@@ -191,6 +197,7 @@ ResetHttps: "Cancel 1Panel https login"
ResetEntrance: "Cancel 1Panel secure entrance"
ResetIPs: "Cancel 1Panel authorized ip restrictions"
ResetDomain: "Cancel 1Panel domain binding"
ResetPasskey: "Clear 1Panel passkeys"
RestoreCommands: "Rollback 1Panel service and data"
RestoreNoSuchFile: "No files available for rollback"
RestoreStep1: "(1/5) Starting rollback of 1Panel service and data from {{ .name }} directory..."
@@ -249,4 +256,4 @@ ErrReqFailed: "{{.name}} request failed: {{ .err }}"
#command
Name: "Name"
Command: "Command"
Command: "Command"
+2 -1
View File
@@ -191,6 +191,7 @@ ResetHttps: "Cancelar inicio de sesión https de 1Panel"
ResetEntrance: "Cancelar entrada segura de 1Panel"
ResetIPs: "Cancelar restricciones de IP autorizadas de 1Panel"
ResetDomain: "Cancelar vinculación de dominio de 1Panel"
ResetPasskey: "Eliminar passkeys de 1Panel"
RestoreCommands: "Revertir servicio y datos de 1Panel"
RestoreNoSuchFile: "No hay archivos disponibles para revertir"
RestoreStep1: "(1/5) Iniciando reversión del servicio y datos de 1Panel desde el directorio {{ .name }}..."
@@ -249,4 +250,4 @@ ErrReqFailed: "{{.name}} petición fallida: {{ .err }}"
#command
Name: "Nombre"
Command: "Comando"
Command: "Comando"
+2 -1
View File
@@ -192,6 +192,7 @@ ResetHttps: "1Panel HTTPSログインをキャンセルします"
ResetEntrance: "1パネルの安全な入り口をキャンセルします"
ResetIPs: "1パネル認定IP制限をキャンセルします"
ResetDomain: "1パネルドメインバインディングをキャンセルします"
ResetPasskey: "1Panel のパスキーをクリアします"
RestoreCommands: "ロールバック1パネルサービスとデータ"
RestoreNoSuchFile: "ロールバックに使用できるファイルはありません"
RestoreStep1: "(1/5){{ .name }}ディレクトリからの1パネルサービスとデータの開始ロールバック..."
@@ -250,4 +251,4 @@ ErrReqFailed: "{{.name}} リクエスト失敗: {{ .err }}"
#command
Name: "名前"
Command: "コマンド"
Command: "コマンド"
+2 -1
View File
@@ -191,6 +191,7 @@ ResetHttps: "1Panel HTTPS 로그인 취소"
ResetEntrance: "1Panel 보안 입구 취소"
ResetIPs: "1Panel 허용 IP 제한 취소"
ResetDomain: "1Panel 도메인 바인딩 취소"
ResetPasskey: "1Panel 패스키 초기화"
RestoreCommands: "1Panel 서비스 및 데이터 복구"
RestoreNoSuchFile: "복구 가능한 파일이 없습니다."
RestoreStep1: "(1/5) {{ .name }} 디렉토리에서 1Panel 서비스 및 데이터 복구 시작..."
@@ -249,4 +250,4 @@ ErrReqFailed: "{{.name}} 요청 실패: {{ .err }}"
#command
Name: "이름"
Command: "명령어"
Command: "명령어"
+2 -1
View File
@@ -186,6 +186,7 @@ ResetHttps: "Batal log masuk HTTPS 1Panel"
ResetEntrance: "Batal pintu masuk keselamatan 1Panel"
ResetIPs: "Batal sekatan IP yang dibenarkan 1Panel"
ResetDomain: "Batal pengikatan domain 1Panel"
ResetPasskey: "Kosongkan passkey 1Panel"
RestoreCommands: "Pulihkan perkhidmatan dan data 1Panel"
RestoreNoSuchFile: "Tiada fail tersedia untuk pemulihan"
RestoreStep1: "(1/5) Memulakan pemulihan perkhidmatan dan data 1Panel daripada direktori {{ .name }}..."
@@ -239,4 +240,4 @@ ErrReqFailed: "{{.name}} permintaan gagal: {{ .err }}"
#command
Name: "Nama"
Command: "Arahan"
Command: "Arahan"
+2 -1
View File
@@ -191,6 +191,7 @@ ResetHttps: "Cancelar login https do 1Panel"
ResetEntrance: "Cancelar entrada segura do 1Panel"
ResetIPs: "Cancelar restrições de IP autorizado do 1Panel"
ResetDomain: "Cancelar vinculação de domínio do 1Panel"
ResetPasskey: "Redefinir passkeys do 1Panel"
RestoreCommands: "Restaurar serviço e dados do 1Panel"
RestoreNoSuchFile: "Nenhum arquivo disponível para restauração"
RestoreStep1: "(1/5) Iniciando a restauração do serviço e dados do 1Panel a partir do diretório {{ .name }}..."
@@ -249,4 +250,4 @@ ErrReqFailed: "{{.name}} solicitação falhou: {{ .err }}"
#command
Name: "Название"
Command: "Команда"
Command: "Команда"
+2 -1
View File
@@ -190,6 +190,7 @@ ResetHttps: "Отменить вход в 1Panel через https"
ResetEntrance: "Отменить безопасный вход 1Panel"
ResetIPs: "Отменить ограничения авторизованных IP для 1Panel"
ResetDomain: "Отменить привязку домена доступа 1Panel"
ResetPasskey: "Сбросить passkey 1Panel"
RestoreCommands: "Откатить сервисы и данные 1Panel"
RestoreNoSuchFile: "Нет доступных для отката файлов"
RestoreStep1: "(1/5)Начинаем откат сервисов и данных 1Panel из каталога {{ .name }}..."
@@ -248,4 +249,4 @@ ErrReqFailed: "{{.name}} запрос не удался: {{ .err }}"
#command
Name: "Название"
Command: "Команда"
Command: "Команда"
+2 -1
View File
@@ -190,6 +190,7 @@ ResetHttps: "1Panel https girişini iptal et"
ResetEntrance: "1Panel güvenli girişi iptal et"
ResetIPs: "1Panel yetkili IP kısıtlamalarını iptal et"
ResetDomain: "1Panel alan adı bağlamasını iptal et"
ResetPasskey: "1Panel passkey'lerini sıfırla"
RestoreCommands: "1Panel servisini ve verilerini geri al"
RestoreNoSuchFile: "Geri alma için mevcut dosya yok"
RestoreStep1: "(1/5) {{ .name }} dizininden 1Panel servisi ve verilerin geri alınması başlatılıyor..."
@@ -248,4 +249,4 @@ ErrReqFailed: "{{.name}} istek başarısız: {{ .err }}"
#command
Name: "Ad"
Command: "Komut"
Command: "Komut"
+2 -1
View File
@@ -193,6 +193,7 @@ ResetHttps: "取消 1Panel https 方式登入"
ResetEntrance: "取消 1Panel 安全入口"
ResetIPs: "取消 1Panel 授權 IP 限制"
ResetDomain: "取消 1Panel 存取域名綁定"
ResetPasskey: "清空 1Panel 通行密鑰"
RestoreCommands: "回滾 1Panel 服務及資料"
RestoreNoSuchFile: "暫無可回滾文件"
RestoreStep1: "(1/5)開始從 {{ .name }} 目錄回滾 1Panel 服務及資料... "
@@ -259,4 +260,4 @@ ErrReqFailed: "{{.name}} 請求失敗: {{ .err }}"
#command
Name: "名稱"
Command: "指令"
Command: "指令"
+8 -1
View File
@@ -14,6 +14,12 @@ ErrApiConfigKeyInvalid: "API 接口密钥错误: {{ .detail }}"
ErrApiConfigIPInvalid: "调用 API 接口 IP 不在白名单: {{ .detail }}"
ErrApiConfigDisable: "此接口禁止使用 API 接口调用: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "API 接口时间戳错误: {{ .detail }}"
ErrPasskeyDisabled: "需开启 HTTPS 才能使用 Passkey"
ErrPasskeyNotConfigured: "尚未配置 Passkey"
ErrPasskeyLimit: "Passkey 数量已达上限(最多 5 个)"
ErrPasskeySession: "Passkey 会话已过期或无效"
ErrPasskeyDuplicate: "Passkey 已存在"
ErrPasskeyVerify: "Passkey 验证失败"
#request
ErrNoSuchHost: "无法找到请求的服务器 {{ .err }}"
@@ -192,6 +198,7 @@ ResetHttps: "取消 1Panel https 方式登录"
ResetEntrance: "取消 1Panel 安全入口"
ResetIPs: "取消 1Panel 授权 IP 限制"
ResetDomain: "取消 1Panel 访问域名绑定"
ResetPasskey: "清空 1Panel 通行密钥"
RestoreCommands: "回滚 1Panel 服务及数据"
RestoreNoSuchFile: "暂无可回滚文件"
RestoreStep1: "(1/5)开始从 {{ .name }} 目录回滚 1Panel 服务及数据... "
@@ -258,4 +265,4 @@ ErrReqFailed: "{{.name}} 请求失败: {{ .err }}"
#command
Name: "名称"
Command: "命令"
Command: "命令"
+1
View File
@@ -15,6 +15,7 @@ func Init() {
migrations.InitHost,
migrations.InitTerminalSetting,
migrations.AddTaskDB,
migrations.AddPasskeySetting,
migrations.AddXpackHideMenu,
migrations.UpdateXpackHideMenu,
migrations.UpdateOnedrive,
+30
View File
@@ -2,6 +2,7 @@ package migrations
import (
"encoding/json"
"errors"
"fmt"
"path"
"strings"
@@ -132,6 +133,12 @@ var InitSetting = &gormigrate.Migration{
if err := tx.Create(&model.Setting{Key: "MFAInterval", Value: "30"}).Error; err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "PasskeyUserID", Value: ""}).Error; err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "PasskeyCredentials", Value: ""}).Error; err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "SystemVersion", Value: global.CONF.Base.Version}).Error; err != nil {
return err
}
@@ -182,6 +189,29 @@ var InitSetting = &gormigrate.Migration{
},
}
var AddPasskeySetting = &gormigrate.Migration{
ID: "20250910-add-passkey-setting",
Migrate: func(tx *gorm.DB) error {
var addSettingsIfMissing = func(tx *gorm.DB, key, value string) error {
var setting model.Setting
if err := tx.Where("key = ?", key).First(&setting).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&model.Setting{Key: key, Value: value}).Error
}
return err
}
return nil
}
if err := addSettingsIfMissing(tx, "PasskeyUserID", ""); err != nil {
return err
}
if err := addSettingsIfMissing(tx, "PasskeyCredentials", ""); err != nil {
return err
}
return nil
},
}
var InitTerminalSetting = &gormigrate.Migration{
ID: "20240814-init-terminal-setting",
Migrate: func(tx *gorm.DB) error {
+2
View File
@@ -12,6 +12,8 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
baseApi := v2.ApiGroupApp.BaseApi
{
baseRouter.GET("/captcha", baseApi.Captcha)
baseRouter.POST("/passkey/begin", baseApi.PasskeyBeginLogin)
baseRouter.POST("/passkey/finish", baseApi.PasskeyFinishLogin)
baseRouter.POST("/mfalogin", baseApi.MFALogin)
baseRouter.POST("/login", baseApi.Login)
baseRouter.POST("/logout", baseApi.LogOut)
+4
View File
@@ -37,6 +37,10 @@ func (s *SettingRouter) InitRouter(Router *gin.RouterGroup) {
settingRouter.POST("/password/update", baseApi.UpdatePassword)
settingRouter.POST("/mfa", baseApi.LoadMFA)
settingRouter.POST("/mfa/bind", baseApi.MFABind)
settingRouter.POST("/passkey/register/begin", baseApi.PasskeyRegisterBegin)
settingRouter.POST("/passkey/register/finish", baseApi.PasskeyRegisterFinish)
settingRouter.GET("/passkey/list", baseApi.PasskeyList)
settingRouter.DELETE("/passkey/:id", baseApi.PasskeyDelete)
settingRouter.POST("/upgrade", baseApi.Upgrade)
settingRouter.POST("/upgrade/notes", baseApi.GetNotesByVersion)
+12
View File
@@ -0,0 +1,12 @@
package passkey
import "github.com/go-webauthn/webauthn/webauthn"
type PasskeyCredentialRecord struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
LastUsedAt string `json:"lastUsedAt"`
FlagsValue uint8 `json:"flagsValue"`
Credential webauthn.Credential `json:"credential"`
}
+86
View File
@@ -0,0 +1,86 @@
package passkey
import (
"crypto/rand"
"encoding/base64"
"sync"
"time"
"github.com/1Panel-dev/1Panel/core/utils/common"
"github.com/go-webauthn/webauthn/webauthn"
)
const (
PasskeyUserIDSettingKey = "PasskeyUserID"
PasskeyCredentialSettingKey = "PasskeyCredentials"
PasskeyMaxCredentials = 5
PasskeySessionTTL = 5 * time.Minute
PasskeySessionKindLogin = "login"
PasskeySessionKindRegister = "register"
PasskeyCredentialNameDefault = "Passkey"
)
var passkeySessions = newPasskeySessionStore()
func GetPasskeySessionStore() *passkeySessionStore {
return passkeySessions
}
type passkeySession struct {
Kind string
Name string
Session webauthn.SessionData
ExpiresAt time.Time
}
type passkeySessionStore struct {
mu sync.Mutex
items map[string]passkeySession
}
func newPasskeySessionStore() *passkeySessionStore {
return &passkeySessionStore{items: make(map[string]passkeySession)}
}
func (s *passkeySessionStore) Set(kind, name string, session webauthn.SessionData) string {
s.mu.Lock()
defer s.mu.Unlock()
sessionID := generatePasskeySessionID()
s.items[sessionID] = passkeySession{
Kind: kind,
Name: name,
Session: session,
ExpiresAt: time.Now().Add(PasskeySessionTTL),
}
return sessionID
}
func (s *passkeySessionStore) Get(sessionID string) (passkeySession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.items[sessionID]
if !ok {
return passkeySession{}, false
}
if time.Now().After(item.ExpiresAt) {
delete(s.items, sessionID)
return passkeySession{}, false
}
return item, true
}
func (s *passkeySessionStore) Delete(sessionID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.items, sessionID)
}
func generatePasskeySessionID() string {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return common.RandStr(32)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
+26
View File
@@ -0,0 +1,26 @@
package passkey
import "github.com/go-webauthn/webauthn/webauthn"
type PasskeyUser struct {
ID []byte
Name string
DisplayName string
Credentials []webauthn.Credential
}
func (u PasskeyUser) WebAuthnID() []byte {
return u.ID
}
func (u PasskeyUser) WebAuthnName() string {
return u.Name
}
func (u PasskeyUser) WebAuthnDisplayName() string {
return u.DisplayName
}
func (u PasskeyUser) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}
+6 -1
View File
@@ -33,7 +33,12 @@ class RequestHttp {
} else {
config.headers.CurrentNode = encodeURIComponent(String(config.headers.CurrentNode));
}
if (config.url === '/core/auth/login' || config.url === '/core/auth/mfalogin') {
if (
config.url === '/core/auth/login' ||
config.url === '/core/auth/mfalogin' ||
config.url === '/core/auth/passkey/begin' ||
config.url === '/core/auth/passkey/finish'
) {
let entrance = Base64.encode(globalStore.entrance);
config.headers.EntranceCode = entrance;
}
+5
View File
@@ -17,6 +17,10 @@ export namespace Login {
token: string;
mfaStatus: string;
}
export interface PasskeyBeginResponse {
sessionId: string;
publicKey: Record<string, any>;
}
export interface ResCaptcha {
imagePath: string;
captchaID: string;
@@ -36,5 +40,6 @@ export namespace Login {
theme: string;
isOffLine: boolean;
needCaptcha: boolean;
passkeySetting: boolean;
}
}
+13
View File
@@ -128,6 +128,19 @@ export namespace Setting {
code: string;
interval: string;
}
export interface PasskeyRegisterRequest {
name: string;
}
export interface PasskeyBeginResponse {
sessionId: string;
publicKey: Record<string, any>;
}
export interface PasskeyInfo {
id: string;
name: string;
createdAt: string;
lastUsedAt: string;
}
export interface CommonDescription {
id: string;
type: string;
+8
View File
@@ -9,6 +9,14 @@ export const mfaLoginApi = (params: Login.MFALoginForm) => {
return http.post<Login.ResLogin>(`/core/auth/mfalogin`, params);
};
export const passkeyBeginApi = () => {
return http.post<Login.PasskeyBeginResponse>(`/core/auth/passkey/begin`);
};
export const passkeyFinishApi = (params: Record<string, any>, sessionId: string) => {
return http.post<Login.ResLogin>(`/core/auth/passkey/finish`, params, undefined, { 'Passkey-Session': sessionId });
};
export const getCaptcha = () => {
return http.get<Login.ResCaptcha>(`/core/auth/captcha`);
};
+12
View File
@@ -142,6 +142,18 @@ export const loadMFA = (param: Setting.MFARequest) => {
export const bindMFA = (param: Setting.MFABind) => {
return http.post(`/core/settings/mfa/bind`, param);
};
export const passkeyRegisterBegin = (param: Setting.PasskeyRegisterRequest) => {
return http.post<Setting.PasskeyBeginResponse>(`/core/settings/passkey/register/begin`, param);
};
export const passkeyRegisterFinish = (param: Record<string, any>, sessionId: string) => {
return http.post(`/core/settings/passkey/register/finish`, param, undefined, { 'Passkey-Session': sessionId });
};
export const passkeyList = () => {
return http.get<Array<Setting.PasskeyInfo>>(`/core/settings/passkey/list`);
};
export const passkeyDelete = (id: string) => {
return http.delete(`/core/settings/passkey/${id}`);
};
export const getAppStoreConfig = (node?: string) => {
const params = node ? `?operateNode=${node}` : '';
return http.get<App.AppStoreConfig>(`/core/settings/apps/store/config${params}`);
+20
View File
@@ -195,6 +195,7 @@ const message = {
login: {
username: 'Username',
password: 'Password',
passkey: 'Passkey Login',
welcome: 'Welcome back, please enter your username and password to log in!',
errorAuthInfo: 'The user name or password you entered is incorrect, please re-enter!',
errorMfaInfo: 'Incorrect authentication information, please try again!',
@@ -216,6 +217,10 @@ const message = {
agreeTitle: 'Agreement',
agreeContent:
'In order to better protect your legitimate rights and interests, please read and agree to the following agreement &laquo; <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Community License Agreement </a> &raquo;',
passkeyFailed: 'Passkey login failed, please try again',
passkeyNotSupported:
'Current browser or environment does not support passkeys, please confirm you have bound the domain name and enabled the panel SSL, and used a trusted certificate when accessing',
passkeyToPassword: 'Have trouble using a passkey? Use password instead',
},
rule: {
username: 'Enter a username',
@@ -1930,6 +1935,21 @@ const message = {
path: 'Path',
safe: 'Security',
passkey: 'Passkey',
passkeyManage: 'Manage',
passkeyHelper: 'For quick login, up to 5 passkeys can be bound',
passkeyRequireSSL: 'Enable HTTPS to use passkeys',
passkeyNotSupported: 'Current browser or environment does not support passkeys',
passkeyCount: 'Bound {0}/{1}',
passkeyName: 'Name',
passkeyNameHelper: 'Enter a name to distinguish devices',
passkeyAdd: 'Add Passkey',
passkeyCreatedAt: 'Created At',
passkeyLastUsedAt: 'Last Used',
passkeyDeleteConfirm: 'After deletion, this passkey cannot be used to log in. Continue?',
passkeyLimit: 'Up to 5 passkeys can be bound',
passkeyFailed:
'Passkey registration failed, please confirm that the panel SSL certificate is a trusted certificate',
bindInfo: 'Bind info',
bindAll: 'Listen All',
bindInfoHelper:
+19
View File
@@ -194,6 +194,7 @@ const message = {
login: {
username: 'Usuario',
password: 'Contraseña',
passkey: 'Inicio de sesión con Passkey',
welcome: '¡Bienvenido de nuevo! Por favor, ingrese su nombre de usuario y contraseña para iniciar sesión.',
errorAuthInfo: 'El nombre de usuario o la contraseña que ingresó son incorrectos, ¡inténtelo de nuevo!',
errorMfaInfo: 'Información de autenticación incorrecta, ¡por favor intente nuevamente!',
@@ -217,6 +218,9 @@ const message = {
agreeTitle: 'Acuerdo',
agreeContent:
'Para proteger mejor sus derechos e intereses legítimos, por favor lea y acepte el siguiente acuerdo &laquo; <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Acuerdo de Licencia Comunitaria </a> &raquo;',
passkeyFailed: 'El inicio de sesión con Passkey falló, inténtalo de nuevo',
passkeyNotSupported: 'El navegador o entorno actual no admite Passkey',
passkeyToPassword: '¿Tienes problemas para usar una passkey? Usa la contraseña en su lugar',
},
rule: {
username: 'Introduzca un nombre de usuario',
@@ -1945,6 +1949,21 @@ const message = {
address: 'Dirección',
path: 'Ruta',
safe: 'Seguridad',
passkey: 'Passkey',
passkeyManage: 'Administrar',
passkeyHelper: 'Para inicio rápido, se pueden vincular hasta 5 passkeys',
passkeyRequireSSL: 'Habilita HTTPS para usar passkeys',
passkeyNotSupported: 'El navegador o entorno actual no admite Passkey',
passkeyCount: 'Vinculadas {0}/{1}',
passkeyName: 'Nombre',
passkeyNameHelper: 'Introduce un nombre para distinguir dispositivos',
passkeyAdd: 'Agregar Passkey',
passkeyCreatedAt: 'Creado',
passkeyLastUsedAt: 'Último uso',
passkeyDeleteConfirm: 'Después de eliminar, no podrás iniciar sesión con esta passkey. ¿Continuar?',
passkeyLimit: 'Se pueden vincular hasta 5 passkeys',
passkeyFailed:
'El registro de passkey falló, por favor, asegúrate de que el certificado SSL del panel es confiable',
bindInfo: 'Información de enlace',
bindAll: 'Escuchar en todas',
bindInfoHelper:
+18
View File
@@ -185,6 +185,7 @@ const message = {
login: {
username: 'ユーザー名',
password: 'パスワード',
passkey: 'パスキーでログイン',
welcome: 'ようこそ、ユーザー名とパスワードを入力してログインしてください!',
errorAuthInfo: '入力したユーザー名またはパスワードは間違っています。再入力してください!',
errorMfaInfo: '認証情報が誤っていない、もう一度やり直してください!',
@@ -206,6 +207,9 @@ const message = {
agreeTitle: '合意',
agreeContent:
'あなたの正当な権利と利益をよりよく保護するために、次の契約とラコを読んで同意してください。<a href = "https://www.fit2cloud.com/legal/licenses.html" target="_blank">コミュニティライセンス契約</a>&raquo;',
passkeyFailed: 'パスキーでのログインに失敗しました。再試行してください',
passkeyNotSupported: '現在のブラウザまたは環境はパスキーに対応していません',
passkeyToPassword: 'パスキーが使えませんか?パスワードでログインしてください',
},
rule: {
username: 'ユーザー名を入力します',
@@ -1857,6 +1861,20 @@ const message = {
path: 'パス',
safe: '安全',
passkey: 'パスキー',
passkeyManage: '管理',
passkeyHelper: '素早くログインするため、最大 5 個のパスキーを登録できます',
passkeyRequireSSL: 'HTTPS を有効にするとパスキーを使用できます',
passkeyNotSupported: '現在のブラウザまたは環境はパスキーに対応していません',
passkeyCount: '登録済み {0}/{1}',
passkeyName: '名称',
passkeyNameHelper: 'デバイスを区別する名称を入力してください',
passkeyAdd: 'パスキーを追加',
passkeyCreatedAt: '作成時間',
passkeyLastUsedAt: '最終使用',
passkeyDeleteConfirm: '削除するとこのパスキーでログインできなくなります。続行しますか?',
passkeyLimit: '最大 5 個のパスキーを登録できます',
passkeyFailed: 'パスキーの登録に失敗しました。パネルSSL証明書が信頼できる証明書であることを確認してください',
bindInfo: 'バインド情報',
bindAll: 'すべてを聞いてください',
bindInfoHelper:
+18
View File
@@ -184,6 +184,7 @@ const message = {
login: {
username: '사용자 이름',
password: '비밀번호',
passkey: '패스키 로그인',
welcome: '다시 오신 것을 환영합니다. 사용자 이름과 비밀번호를 입력하여 로그인하세요!',
errorAuthInfo: '입력한 사용자 이름 또는 비밀번호가 잘못되었습니다. 다시 입력해주세요!',
errorMfaInfo: '인증 정보가 잘못되었습니다. 다시 시도해주세요!',
@@ -205,6 +206,9 @@ const message = {
agreeTitle: '동의',
agreeContent:
'귀하의 합법적인 권리와 이익을 보다 잘 보호하기 위해, 다음 &laquo; <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" >커뮤니티 라이선스 계약</a> &raquo;을 읽고 동의해주세요.',
passkeyFailed: '패스키 로그인에 실패했습니다. 다시 시도하세요',
passkeyNotSupported: '현재 브라우저 또는 환경에서 패스키를 지원하지 않습니다',
passkeyToPassword: '패스키 사용에 문제가 있나요? 비밀번호로 로그인하세요',
},
rule: {
username: '사용자 이름을 입력하세요',
@@ -1833,6 +1837,20 @@ const message = {
path: '경로',
safe: '보안',
passkey: '패스키',
passkeyManage: '관리',
passkeyHelper: '빠른 로그인을 위해 최대 5개의 패스키를 등록할 수 있습니다',
passkeyRequireSSL: 'HTTPS를 활성화하면 패스키를 사용할 수 있습니다',
passkeyNotSupported: '현재 브라우저 또는 환경에서 패스키를 지원하지 않습니다',
passkeyCount: '등록됨 {0}/{1}',
passkeyName: '이름',
passkeyNameHelper: '기기를 구분할 이름을 입력하세요',
passkeyAdd: '패스키 추가',
passkeyCreatedAt: '생성 시간',
passkeyLastUsedAt: '최근 사용',
passkeyDeleteConfirm: '삭제하면 이 패스키로 로그인할 수 없습니다. 계속하시겠습니까?',
passkeyLimit: '최대 5개의 패스키를 등록할 수 있습니다',
passkeyFailed: '패스키 등록에 실패했습니다. 패널 SSL 인증서가 신뢰할 수 있는지 확인하세요',
bindInfo: '바인딩 정보',
bindAll: '모두 수신',
bindInfoHelper: '서비스 수신 주소나 프로토콜 변경은 서비스 불가 상태를 초래할 수 있습니다. 계속하시겠습니까?',
+19
View File
@@ -187,6 +187,7 @@ const message = {
login: {
username: 'Nama Pengguna',
password: 'Kata Laluan',
passkey: 'Log masuk Passkey',
welcome: 'Selamat datang kembali, sila masukkan nama pengguna dan kata laluan anda untuk log masuk!',
errorAuthInfo: 'Nama pengguna atau kata laluan yang anda masukkan tidak betul, sila cuba lagi!',
errorMfaInfo: 'Maklumat pengesahan tidak betul, sila cuba lagi!',
@@ -208,6 +209,9 @@ const message = {
agreeTitle: 'Agreement',
agreeContent:
'Untuk melindungi hak dan kepentingan sah anda dengan lebih baik, sila baca dan setuju dengan perjanjian berikut &laquo; <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Perjanjian Lesen Komuniti </a> &raquo;',
passkeyFailed: 'Log masuk Passkey gagal, sila cuba lagi',
passkeyNotSupported: 'Pelayar atau persekitaran semasa tidak menyokong passkey',
passkeyToPassword: 'Ada masalah menggunakan passkey? Gunakan kata laluan sahaja',
},
rule: {
username: 'Masukkan nama pengguna',
@@ -1911,6 +1915,21 @@ const message = {
path: 'Laluan',
safe: 'Keselamatan',
passkey: 'Passkey',
passkeyManage: 'Urus',
passkeyHelper: 'Untuk log masuk pantas, maksimum 5 passkey boleh dipautkan',
passkeyRequireSSL: 'Aktifkan HTTPS untuk menggunakan passkey',
passkeyNotSupported: 'Pelayar atau persekitaran semasa tidak menyokong passkey',
passkeyCount: 'Dipaut {0}/{1}',
passkeyName: 'Nama',
passkeyNameHelper: 'Masukkan nama untuk membezakan peranti',
passkeyAdd: 'Tambah Passkey',
passkeyCreatedAt: 'Dicipta',
passkeyLastUsedAt: 'Terakhir digunakan',
passkeyDeleteConfirm: 'Selepas dipadam, passkey ini tidak boleh digunakan untuk log masuk. Teruskan?',
passkeyLimit: 'Maksimum 5 passkey boleh dipautkan',
passkeyFailed:
'Pendaftaran passkey gagal, sila pastikan sertifikat SSL panel adalah sertifikat yang dipercayai',
bindInfo: 'Maklumat ikatan',
bindAll: 'Dengar Semua',
bindInfoHelper:
+19
View File
@@ -185,6 +185,7 @@ const message = {
login: {
username: 'Usuário',
password: 'Senha',
passkey: 'Login com Passkey',
welcome: 'Bem-vindo de volta, insira seu usuário e senha para acessar!',
errorAuthInfo: 'O nome de usuário ou senha que você inseriu está incorreto, tente novamente!',
errorMfaInfo: 'Informações de autenticação incorretas, tente novamente!',
@@ -207,6 +208,9 @@ const message = {
agreeTitle: 'Termo de Aceite',
agreeContent:
'Para proteger melhor seus direitos e interesses legítimos, leia e concorde com o seguinte termo &laquo; <a href="https://www.fit2cloud.com/legal/licenses.html" target="_blank">Acordo de Licença Comunitária</a> &raquo;',
passkeyFailed: 'Falha no login com Passkey, tente novamente',
passkeyNotSupported: 'O navegador ou ambiente atual não suporta passkeys',
passkeyToPassword: 'Está com problemas para usar uma passkey? Use a senha',
},
rule: {
username: 'Insira um nome de usuário',
@@ -1899,6 +1903,21 @@ const message = {
path: 'Caminho',
safe: 'Segurança',
passkey: 'Passkey',
passkeyManage: 'Gerenciar',
passkeyHelper: 'Para login rápido, é possível vincular até 5 passkeys',
passkeyRequireSSL: 'Ative o HTTPS para usar passkeys',
passkeyNotSupported: 'O navegador ou ambiente atual não suporta passkeys',
passkeyCount: 'Vinculadas {0}/{1}',
passkeyName: 'Nome',
passkeyNameHelper: 'Digite um nome para distinguir dispositivos',
passkeyAdd: 'Adicionar Passkey',
passkeyCreatedAt: 'Criado em',
passkeyLastUsedAt: 'Último uso',
passkeyDeleteConfirm: 'Após excluir, esta passkey não poderá ser usada para login. Continuar?',
passkeyLimit: 'É possível vincular até 5 passkeys',
passkeyFailed:
'Falha no cadastro da passkey, por favor, certifique-se de que o certificado SSL do painel é confiável',
bindInfo: 'Informações de Vinculação',
bindAll: 'Ouvir Todos',
bindInfoHelper:
+19
View File
@@ -185,6 +185,7 @@ const message = {
login: {
username: 'Имя пользователя',
password: 'Пароль',
passkey: 'Вход по passkey',
welcome: 'Добро пожаловать, введите имя пользователя и пароль для входа!',
errorAuthInfo: 'Введенное имя пользователя или пароль неверны, пожалуйста, введите заново!',
errorMfaInfo: 'Неверная информация аутентификации, попробуйте еще раз!',
@@ -206,6 +207,9 @@ const message = {
agreeTitle: 'Соглашение',
agreeContent:
'Для лучшей защиты ваших законных прав и интересов, пожалуйста, прочитайте и согласитесь со следующим соглашением &laquo; <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Лицензионное соглашение сообщества </a> &raquo;',
passkeyFailed: 'Вход по passkey не удался, попробуйте снова',
passkeyNotSupported: 'Текущий браузер или среда не поддерживает passkey',
passkeyToPassword: 'Не получается использовать passkey? Войдите по паролю',
},
rule: {
username: 'Введите имя пользователя',
@@ -1900,6 +1904,21 @@ const message = {
path: 'Путь',
safe: 'Безопасность',
passkey: 'Passkey',
passkeyManage: 'Управление',
passkeyHelper: 'Для быстрого входа можно привязать до 5 passkey',
passkeyRequireSSL: 'Включите HTTPS, чтобы использовать passkey',
passkeyNotSupported: 'Текущий браузер или среда не поддерживает passkey',
passkeyCount: 'Привязано {0}/{1}',
passkeyName: 'Название',
passkeyNameHelper: 'Введите название для различения устройств',
passkeyAdd: 'Добавить passkey',
passkeyCreatedAt: 'Создано',
passkeyLastUsedAt: 'Последнее использование',
passkeyDeleteConfirm: 'После удаления эту passkey нельзя использовать для входа. Продолжить?',
passkeyLimit: 'Можно привязать до 5 passkey',
passkeyFailed:
'Не удалось зарегистрировать passkey, пожалуйста, убедитесь, что сертификат SSL панели является доверенным',
bindInfo: 'IP-адрес',
bindAll: 'Прослушивать все',
bindInfoHelper:
+18
View File
@@ -196,6 +196,7 @@ const message = {
login: {
username: 'Kullanıcı adı',
password: 'Şifre',
passkey: 'Passkey ile giriş',
welcome: 'Tekrar hoş geldiniz, giriş yapmak için kullanıcı adınızı ve şifrenizi girin!',
errorAuthInfo: 'Girdiğiniz kullanıcı adı veya şifre yanlış, lütfen tekrar girin!',
errorMfaInfo: 'Yanlış kimlik doğrulama bilgisi, lütfen tekrar deneyin!',
@@ -217,6 +218,9 @@ const message = {
agreeTitle: 'Sözleşme',
agreeContent:
'Meşru hak ve çıkarlarınızı daha iyi korumak için lütfen aşağıdaki sözleşmeyi okuyun ve kabul edin &laquo; <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Topluluk Lisans Sözleşmesi </a> &raquo;',
passkeyFailed: 'Passkey ile giriş başarısız oldu, lütfen tekrar deneyin',
passkeyNotSupported: 'Mevcut tarayıcı veya ortam passkey desteklemiyor',
passkeyToPassword: 'Passkey kullanırken sorun mu yaşıyorsunuz? Şifreyi kullanın',
},
rule: {
username: 'Kullanıcı adı girin',
@@ -1952,6 +1956,20 @@ const message = {
address: 'Adres',
path: 'Yol',
safe: 'Güvenlik',
passkey: 'Passkey',
passkeyManage: 'Yönet',
passkeyHelper: 'Hızlı giriş için en fazla 5 passkey bağlanabilir',
passkeyRequireSSL: 'Passkey kullanmak için HTTPS’i etkinleştirin',
passkeyNotSupported: 'Mevcut tarayıcı veya ortam passkey desteklemiyor',
passkeyCount: 'Bağlı {0}/{1}',
passkeyName: 'Ad',
passkeyNameHelper: 'Cihazları ayırt etmek için bir ad girin',
passkeyAdd: 'Passkey ekle',
passkeyCreatedAt: 'Oluşturulma',
passkeyLastUsedAt: 'Son kullanım',
passkeyDeleteConfirm: 'Silindikten sonra bu passkey ile giriş yapılamaz. Devam edilsin mi?',
passkeyLimit: 'En fazla 5 passkey bağlanabilir',
passkeyFailed: 'Passkey kaydı başarısız oldu, lütfen panel SSL sertifikasının güvenilir olduğundan emin olun',
bindInfo: 'Bağlama bilgisi',
bindAll: 'Tümünü Dinle',
bindInfoHelper:
+19
View File
@@ -192,6 +192,7 @@ const message = {
login: {
username: '使用者名稱',
password: '密碼',
passkey: '通行密鑰登入',
welcome: '歡迎回來,請輸入使用者名稱和密碼登入!',
errorAuthInfo: '您輸入的使用者名稱或密碼不正確,請重新輸入!',
errorMfaInfo: '錯誤的驗證資訊,請重試!',
@@ -213,6 +214,9 @@ const message = {
agreeContent:
'為了更好的保障您的合法權益,請您閱讀並同意以下協議 &laquo; <a href="https://www.fit2cloud.com/legal/licenses.html" target="_blank"> 飛致雲社區軟體許可協議 </a> &raquo;',
logout: '退出登入',
passkeyFailed: '通行密鑰登入失敗,請重試',
passkeyNotSupported: '目前瀏覽器或環境不支援通行密鑰',
passkeyToPassword: '無法使用通行密鑰? 使用帳號密碼登入',
},
rule: {
username: '請輸入使用者名稱',
@@ -1893,6 +1897,21 @@ const message = {
versionCompare: '檢測到節點 {0} 版本已是目前可升級最新版本,請檢查主節點版本後重試!',
safe: '安全',
passkey: '通行密鑰',
passkeyManage: '管理',
passkeyHelper: '用於快速登入,最多可綁定 5 個通行密鑰',
passkeyRequireSSL: '啟用 HTTPS 後可使用通行密鑰',
passkeyNotSupported:
'目前瀏覽器或環境不支援通行密鑰 \n 請確認您已綁定域名並啟用面板 SSL, 並且訪問時使用了可信的證書',
passkeyCount: '已綁定 {0}/{1}',
passkeyName: '名稱',
passkeyNameHelper: '請輸入用於區分裝置的名稱',
passkeyAdd: '新增通行密鑰',
passkeyCreatedAt: '建立時間',
passkeyLastUsedAt: '最近使用',
passkeyDeleteConfirm: '刪除後將無法使用此通行密鑰登入,是否繼續?',
passkeyLimit: '最多可綁定 5 個通行密鑰',
passkeyFailed: '通行密鑰註冊失敗,請確認面板SSL證書是否為可信證書',
bindInfo: '監聽地址',
bindAll: '監聽所有',
bindInfoHelper: '修改服務監聽地址或協議可能導致服務不可用,是否繼續?',
+19
View File
@@ -206,6 +206,10 @@ const message = {
codeInput: '请输入 MFA 验证器的 6 位验证码',
mfaTitle: 'MFA 认证',
mfaCode: 'MFA 验证码',
passkey: '使用通行密钥',
passkeyFailed: '通行密钥登录失败,请重试',
passkeyNotSupported: '当前浏览器或环境不支持通行密钥',
passkeyToPassword: '无法使用通行密钥? 使用账号密码登录',
title: 'Linux 服务器运维管理面板',
licenseHelper: '《飞致云社区软件许可协议》',
errorAgree: '请点击同意社区软件许可协议',
@@ -1939,6 +1943,21 @@ const message = {
mfaInterval: '刷新时间(秒)',
mfaTitleHelper: '用于区分不同 1Panel 主机,修改后请重新扫描或手动添加密钥信息!',
mfaIntervalHelper: '修改刷新时间后,请重新扫描或手动添加密钥信息!',
passkey: '通行密钥',
passkeyManage: '管理',
passkeyHelper: '用于快速登录,最多可绑定 5 个通行密钥',
passkeyRequireSSL: '开启 HTTPS 后可使用通行密钥',
passkeyNotSupported:
'当前浏览器或环境不支持通行密钥 \n 请确认您已经设置域名绑定并启用了面板 SSL, 并且访问时使用了可信的证书',
passkeyCount: '已绑定 {0}/{1}',
passkeyName: '名称',
passkeyNameHelper: '请输入用于区分设备的名称',
passkeyAdd: '添加通行密钥',
passkeyCreatedAt: '创建时间',
passkeyLastUsedAt: '最近使用',
passkeyDeleteConfirm: '删除后将无法使用该通行密钥登录,是否继续?',
passkeyLimit: '最多可绑定 5 个通行密钥',
passkeyFailed: '通行密钥注册失败,请确认面板SSL证书是否为可信证书',
sslChangeHelper: 'https 设置修改需要重启服务,是否继续?',
sslDisable: '禁用',
sslDisableHelper: '禁用 https 服务,需要重启面板才能生效,是否继续?',
+24
View File
@@ -947,3 +947,27 @@ export function GetPunyCodeDomain(domain: string): string {
return '';
}
}
export function base64UrlToBuffer(base64url: string): ArrayBuffer {
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
if (pad) {
base64 += '='.repeat(4 - pad);
}
const binary = window.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
export function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
const base64 = window.btoa(binary);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
@@ -39,39 +39,93 @@
</div>
</el-form>
</div>
<div v-else>
<el-form ref="loginFormRef" :model="loginForm" size="default" :rules="loginRules">
<div class="flex justify-between items-center mb-6">
<div class="text-2xl font-medium text-gray-900">{{ $t('commons.button.login') }}</div>
<div class="cursor-pointer">
<el-dropdown @command="handleCommand">
<span class="flex items-center space-x-1">
{{ dropdownText }}
<el-icon>
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="globalStore.isIntl" command="en">
English
</el-dropdown-item>
<el-dropdown-item command="zh">中文(简体)</el-dropdown-item>
<el-dropdown-item command="zh-Hant">中文(繁體)</el-dropdown-item>
<el-dropdown-item v-if="!globalStore.isIntl" command="en">
English
</el-dropdown-item>
<el-dropdown-item command="ja">日本語</el-dropdown-item>
<el-dropdown-item command="pt-BR">Português (Brasil)</el-dropdown-item>
<el-dropdown-item command="ko">한국어</el-dropdown-item>
<el-dropdown-item command="ru">Русский</el-dropdown-item>
<el-dropdown-item command="ms">Bahasa Melayu</el-dropdown-item>
<el-dropdown-item command="tr">Turkish</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div v-else-if="showPasskeyOnly">
<div class="flex justify-between items-center mb-6">
<div class="text-2xl font-medium text-gray-900">{{ $t('commons.button.login') }}</div>
<div class="cursor-pointer">
<el-dropdown @command="handleCommand">
<span class="flex items-center space-x-1">
{{ dropdownText }}
<el-icon>
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="globalStore.isIntl" command="en">English</el-dropdown-item>
<el-dropdown-item command="zh">中文(简体)</el-dropdown-item>
<el-dropdown-item command="zh-Hant">中文(繁體)</el-dropdown-item>
<el-dropdown-item v-if="!globalStore.isIntl" command="en">English</el-dropdown-item>
<el-dropdown-item command="ja">日本語</el-dropdown-item>
<el-dropdown-item command="pt-BR">Português (Brasil)</el-dropdown-item>
<el-dropdown-item command="ko">한국어</el-dropdown-item>
<el-dropdown-item command="ru">Русский</el-dropdown-item>
<el-dropdown-item command="ms">Bahasa Melayu</el-dropdown-item>
<el-dropdown-item command="tr">Turkish</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div class="space-y-6">
<el-form-item>
<el-button class="w-full login-button" type="primary" size="default" @click="passkeyLogin">
<el-icon class="mr-2"><Key /></el-icon>
{{ $t('commons.login.passkey') }}
</el-button>
</el-form-item>
<el-form-item>
<el-link type="primary" :underline="false" @click="switchToPasswordLogin">
{{ $t('commons.login.passkeyToPassword') }}
</el-link>
</el-form-item>
<el-form-item v-if="!isIntl && !isFxplay">
<el-checkbox v-model="loginForm.agreeLicense">
<template #default>
<span class="agree-title">
{{ $t('commons.button.agree') }}
<a
class="agree"
href="https://www.fit2cloud.com/legal/licenses.html"
target="_blank"
>
{{ $t('commons.login.licenseHelper') }}
</a>
</span>
</template>
</el-checkbox>
</el-form-item>
</div>
</div>
<div v-else>
<div class="flex justify-between items-center mb-6">
<div class="text-2xl font-medium text-gray-900">{{ $t('commons.button.login') }}</div>
<div class="cursor-pointer">
<el-dropdown @command="handleCommand">
<span class="flex items-center space-x-1">
{{ dropdownText }}
<el-icon>
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="globalStore.isIntl" command="en">English</el-dropdown-item>
<el-dropdown-item command="zh">中文(简体)</el-dropdown-item>
<el-dropdown-item command="zh-Hant">中文(繁體)</el-dropdown-item>
<el-dropdown-item v-if="!globalStore.isIntl" command="en">English</el-dropdown-item>
<el-dropdown-item command="ja">日本語</el-dropdown-item>
<el-dropdown-item command="pt-BR">Português (Brasil)</el-dropdown-item>
<el-dropdown-item command="ko">한국어</el-dropdown-item>
<el-dropdown-item command="ru">Русский</el-dropdown-item>
<el-dropdown-item command="ms">Bahasa Melayu</el-dropdown-item>
<el-dropdown-item command="tr">Turkish</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-form ref="loginFormRef" :model="loginForm" size="default" :rules="loginRules">
<div class="space-y-6 flex-grow">
<el-form-item prop="name" class="w-full">
<el-input
@@ -186,14 +240,22 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue';
import type { ElForm } from 'element-plus';
import { loginApi, getCaptcha, mfaLoginApi, getLoginSetting } from '@/api/modules/auth';
import {
loginApi,
getCaptcha,
mfaLoginApi,
getLoginSetting,
passkeyBeginApi,
passkeyFinishApi,
} from '@/api/modules/auth';
import { GlobalStore, MenuStore, TabsStore } from '@/store';
import { MsgError, MsgSuccess } from '@/utils/message';
import { useI18n } from 'vue-i18n';
import { encryptPassword } from '@/utils/util';
import { encryptPassword, base64UrlToBuffer, bufferToBase64Url } from '@/utils/util';
import { getXpackSettingForTheme } from '@/utils/xpack';
import { routerToName } from '@/utils/router';
import { changeToLocal, setDefaultNodeInfo } from '@/utils/node';
import { Key } from '@element-plus/icons-vue';
const i18n = useI18n();
const themeConfig = computed(() => globalStore.themeConfig);
@@ -204,6 +266,9 @@ const tabsStore = TabsStore();
const errAuthInfo = ref(false);
const errCaptcha = ref(false);
const errMfaInfo = ref(false);
const passkeySetting = ref(false);
const passkeySupported = ref(false);
const showPasswordLogin = ref(false);
const isDemo = ref(false);
const isIntl = ref(true);
const isFxplay = ref(false);
@@ -257,6 +322,7 @@ let isLoggingIn = false;
const userNameRef = ref();
const mfaLoginRef = ref();
const mfaButtonFocused = ref();
const pendingLoginMethod = ref<'password' | 'passkey'>('password');
const mfaLoginForm = reactive({
name: '',
password: '',
@@ -297,9 +363,24 @@ const handleCommand = async (command: string) => {
const agreeWithLogin = () => {
open.value = false;
loginForm.agreeLicense = true;
if (pendingLoginMethod.value === 'passkey') {
passkeyLogin();
return;
}
login(loginFormRef.value);
};
const showPasskeyOnly = computed(() => {
return passkeySetting.value && passkeySupported.value && !showPasswordLogin.value;
});
const switchToPasswordLogin = () => {
showPasswordLogin.value = true;
nextTick(() => {
userNameRef.value?.focus();
});
};
const login = (formEl: FormInstance | undefined) => {
if (!formEl || isLoggingIn) return;
errAuthInfo.value = false;
@@ -311,6 +392,7 @@ const login = (formEl: FormInstance | undefined) => {
}
if (!loginForm.agreeLicense) {
if (_isMobile()) {
pendingLoginMethod.value = 'password';
open.value = true;
}
return;
@@ -407,6 +489,88 @@ const mfaLogin = async (auto: boolean) => {
}
}
};
const passkeyLogin = async () => {
if (isLoggingIn || !passkeySetting.value) return;
if (!passkeySupported.value) {
MsgError(i18n.t('commons.login.passkeyNotSupported'));
return;
}
if (!isIntl.value && !isFxplay.value && !loginForm.agreeLicense) {
if (_isMobile() || showPasskeyOnly.value) {
pendingLoginMethod.value = 'passkey';
open.value = true;
} else {
MsgError(i18n.t('commons.login.errorAgree'));
}
return;
}
try {
isLoggingIn = true;
loading.value = true;
const res = await passkeyBeginApi();
const publicKey = normalizePasskeyRequest(res.data.publicKey);
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null;
if (!credential) {
MsgError(i18n.t('commons.login.passkeyFailed'));
return;
}
const payload = buildPasskeyAssertion(credential);
await passkeyFinishApi(payload, res.data.sessionId);
globalStore.ignoreCaptcha = true;
globalStore.setLogStatus(true);
globalStore.setAgreeLicense(true);
menuStore.setMenuList([]);
tabsStore.removeAllTabs();
changeToLocal();
MsgSuccess(i18n.t('commons.msg.loginSuccess'));
setDefaultNodeInfo();
localStorage.removeItem('dashboardCache');
localStorage.removeItem('upgradeChecked');
routerToName('home');
document.onkeydown = null;
} catch (res: any) {
if (res?.message) {
MsgError(i18n.t('commons.login.passkeyFailed'));
console.log(res.message);
}
} finally {
isLoggingIn = false;
loading.value = false;
}
};
const normalizePasskeyRequest = (publicKey: Record<string, any>): PublicKeyCredentialRequestOptions => {
const request = { ...publicKey };
request.challenge = base64UrlToBuffer(request.challenge);
if (request.allowCredentials && Array.isArray(request.allowCredentials)) {
request.allowCredentials = request.allowCredentials.map((item) => {
return { ...item, id: base64UrlToBuffer(item.id) };
});
}
return request as PublicKeyCredentialRequestOptions;
};
const buildPasskeyAssertion = (credential: PublicKeyCredential) => {
const response = credential.response as AuthenticatorAssertionResponse;
const payload: Record<string, any> = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
},
clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: credential.authenticatorAttachment,
};
if (response.userHandle) {
payload.response.userHandle = bufferToBase64Url(response.userHandle);
}
return payload;
};
const loginVerify = async () => {
const res = await getCaptcha();
captcha.imagePath = res.data.imagePath ? res.data.imagePath : '';
@@ -425,6 +589,7 @@ const getSetting = async () => {
globalStore.isFxplay = isFxplay.value;
globalStore.isOffLine = res.data.isOffLine;
globalStore.ignoreCaptcha = !res.data.needCaptcha;
passkeySetting.value = res.data.passkeySetting;
if (!globalStore.ignoreCaptcha) {
loginVerify();
}
@@ -433,6 +598,10 @@ const getSetting = async () => {
i18n.warnHtmlMessage = false;
globalStore.setOpenMenuTabs(res.data.menuTabs === 'Enable');
globalStore.setThemeConfig({ ...themeConfig.value, theme: res.data.theme, panelName: res.data.panelName });
if (res.data.passkeySetting && !isIntl.value && !isFxplay.value) {
loginForm.agreeLicense = true;
}
} catch (error) {}
};
@@ -479,6 +648,7 @@ function adjustColorToRGBA(color: string, percent: number, opacity: number): str
onMounted(() => {
globalStore.isOnRestart = false;
passkeySupported.value = !!window.PublicKeyCredential && window.isSecureContext;
getSetting();
getXpackSettingForTheme();
if (!globalStore.ignoreCaptcha) {
@@ -518,6 +688,7 @@ onMounted(() => {
.agree {
text-decoration: none;
}
.agree:hover {
text-decoration: underline;
}
@@ -540,6 +711,7 @@ onMounted(() => {
background-color: var(--login-btn-link-color);
border-color: var(--login-btn-link-color);
color: #ffffff;
&:hover {
background-color: var(--login-btn-link-hover-color) !important;
border-color: var(--login-btn-link-hover-color) !important;
@@ -555,12 +727,14 @@ onMounted(() => {
:deep(.el-input__wrapper) {
background: none !important;
}
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--login-btn-link-color) inset !important;
}
.demo {
text-align: center;
span {
color: red;
}
@@ -577,6 +751,7 @@ onMounted(() => {
:deep(a) {
color: var(--login-btn-link-color);
&:hover {
opacity: 75%;
}
@@ -600,6 +775,7 @@ onMounted(() => {
color: #000 !important;
}
}
.cursor-pointer {
outline: none;
}
@@ -616,6 +792,7 @@ onMounted(() => {
background-color: var(--login-btn-link-color) !important;
color: #fff !important;
}
:deep(.el-dropdown-menu__item:not(.is-disabled):focus) {
background-color: var(--login-btn-link-color) !important;
color: #fff !important;
@@ -623,6 +800,7 @@ onMounted(() => {
:deep(.el-loading-mask) {
background-color: var(--login-loading-mask-color) !important;
.el-loading-spinner .path {
stroke: var(--login-btn-link-color);
}
+190 -2
View File
@@ -164,6 +164,18 @@
{{ $t('setting.mfaHelper') }}
</span>
</el-form-item>
<el-form-item :label="$t('setting.passkey')">
<el-button
@click="openPasskeyDialog"
:disabled="!passkeySupported || form.ssl === 'Disable'"
>
{{ $t('setting.passkeyManage') }}
</el-button>
<span class="input-help">
{{ passkeyHint }}
</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
@@ -179,6 +191,38 @@
<DomainSetting ref="domainRef" @search="search" />
<AllowIPsSetting ref="allowIPsRef" @search="search" />
<ResponseSetting ref="responseRef" @search="search()" />
<el-dialog v-model="passkeyDialogVisible" :title="$t('setting.passkey')" width="600px">
<div class="mb-4">
<el-form label-position="top">
<el-form-item :label="$t('setting.passkeyName')">
<el-input v-model.trim="passkeyForm.name" :placeholder="$t('setting.passkeyNameHelper')" />
</el-form-item>
<div class="flex items-center">
<el-button type="primary" @click="registerPasskey" :disabled="!canRegisterPasskey">
{{ $t('setting.passkeyAdd') }}
</el-button>
<span class="text-xs text-gray-500 ml-3">{{ passkeyCountText }}</span>
</div>
</el-form>
</div>
<el-table :data="passkeyList" v-loading="passkeyLoading">
<el-table-column prop="name" :label="$t('setting.passkeyName')" min-width="120" />
<el-table-column prop="createdAt" :label="$t('setting.passkeyCreatedAt')" min-width="160" />
<el-table-column :label="$t('setting.passkeyLastUsedAt')" min-width="160">
<template #default="scope">
<span>{{ scope.row.lastUsedAt || '-' }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="120">
<template #default="scope">
<el-button link type="danger" @click="removePasskey(scope.row.id)">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
@@ -194,11 +238,22 @@ import TimeoutSetting from '@/views/setting/safe/timeout/index.vue';
import EntranceSetting from '@/views/setting/safe/entrance/index.vue';
import DomainSetting from '@/views/setting/safe/domain/index.vue';
import AllowIPsSetting from '@/views/setting/safe/allowips/index.vue';
import { updateSetting, getSettingInfo, getSystemAvailable, updateSSL, loadSSLInfo } from '@/api/modules/setting';
import {
updateSetting,
getSettingInfo,
getSystemAvailable,
updateSSL,
loadSSLInfo,
passkeyRegisterBegin,
passkeyRegisterFinish,
passkeyList as fetchPasskeyList,
passkeyDelete,
} from '@/api/modules/setting';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { MsgError, MsgSuccess } from '@/utils/message';
import { Setting } from '@/api/interface/setting';
import { GlobalStore } from '@/store';
import { base64UrlToBuffer, bufferToBase64Url } from '@/utils/util';
const globalStore = GlobalStore();
const loading = ref(false);
@@ -237,6 +292,33 @@ const form = reactive({
noAuthSettingValue: '200',
});
const passkeyDialogVisible = ref(false);
const passkeyLoading = ref(false);
const passkeyList = ref<Setting.PasskeyInfo[]>([]);
const passkeyForm = reactive({ name: '' });
const passkeySupported = ref(false);
const passkeyMaxCount = 5;
const passkeyHint = computed(() => {
if (form.ssl === 'Disable') {
return i18n.global.t('setting.passkeyRequireSSL');
}
if (!passkeySupported.value) {
return i18n.global.t('setting.passkeyNotSupported');
}
return i18n.global.t('setting.passkeyHelper');
});
const passkeyCountText = computed(() => {
return i18n.global.t('setting.passkeyCount', [passkeyList.value.length, passkeyMaxCount]);
});
const canRegisterPasskey = computed(() => {
return (
form.ssl !== 'Disable' &&
passkeySupported.value &&
passkeyList.value.length < passkeyMaxCount &&
passkeyForm.name.trim().length > 0
);
});
const unset = ref(i18n.global.t('setting.unSetting'));
const search = async () => {
@@ -259,6 +341,9 @@ const search = async () => {
form.mfaInterval = Number(res.data.mfaInterval);
form.allowIPs = res.data.allowIPs.replaceAll(',', '\n');
form.bindDomain = res.data.bindDomain;
if (res.data.bindDomain === '') {
passkeySupported.value = false;
}
form.noAuthSettingValue = res.data.noAuthSetting;
if (res.data.noAuthSetting !== '200') {
form.noAuthSetting = res.data.noAuthSetting + ' - ' + i18n.global.t('setting.error' + res.data.noAuthSetting);
@@ -311,6 +396,108 @@ const handleMFA = async () => {
});
};
const openPasskeyDialog = async () => {
passkeyDialogVisible.value = true;
await loadPasskeys();
};
const loadPasskeys = async () => {
passkeyLoading.value = true;
try {
const res = await fetchPasskeyList();
passkeyList.value = res.data || [];
} catch (error) {
passkeyList.value = [];
} finally {
passkeyLoading.value = false;
}
};
const registerPasskey = async () => {
if (form.ssl === 'Disable') {
MsgError(i18n.global.t('setting.passkeyRequireSSL'));
return;
}
if (!passkeySupported.value) {
MsgError(i18n.global.t('setting.passkeyNotSupported'));
return;
}
if (passkeyList.value.length >= passkeyMaxCount) {
MsgError(i18n.global.t('setting.passkeyLimit'));
return;
}
if (!passkeyForm.name.trim()) {
MsgError(i18n.global.t('commons.rule.requiredInput'));
return;
}
passkeyLoading.value = true;
try {
const res = await passkeyRegisterBegin({ name: passkeyForm.name.trim() });
const publicKey = normalizePasskeyCreation(res.data.publicKey);
const credential = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null;
if (!credential) {
MsgError(i18n.global.t('setting.passkeyFailed'));
return;
}
const payload = buildPasskeyAttestation(credential);
await passkeyRegisterFinish(payload, res.data.sessionId);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
passkeyForm.name = '';
await loadPasskeys();
} catch (res: any) {
if (res?.message) {
console.log(res.message);
MsgError(i18n.global.t('setting.passkeyFailed'));
}
} finally {
passkeyLoading.value = false;
}
};
const removePasskey = async (id: string) => {
ElMessageBox.confirm(i18n.global.t('setting.passkeyDeleteConfirm'), i18n.global.t('setting.passkey'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
})
.then(async () => {
passkeyLoading.value = true;
await passkeyDelete(id);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
await loadPasskeys();
})
.catch(() => {})
.finally(() => {
passkeyLoading.value = false;
});
};
const normalizePasskeyCreation = (publicKey: Record<string, any>): PublicKeyCredentialCreationOptions => {
const request = { ...publicKey };
request.challenge = base64UrlToBuffer(request.challenge);
request.user = { ...request.user, id: base64UrlToBuffer(request.user.id) };
if (request.excludeCredentials && Array.isArray(request.excludeCredentials)) {
request.excludeCredentials = request.excludeCredentials.map((item) => {
return { ...item, id: base64UrlToBuffer(item.id) };
});
}
return request as PublicKeyCredentialCreationOptions;
};
const buildPasskeyAttestation = (credential: PublicKeyCredential) => {
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
},
clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: credential.authenticatorAttachment,
};
};
const onChangeEntrance = () => {
entranceRef.value.acceptParams({ securityEntrance: form.securityEntrance });
};
@@ -390,6 +577,7 @@ function loadTimeOut() {
}
onMounted(() => {
passkeySupported.value = !!window.PublicKeyCredential && window.isSecureContext;
search();
getSystemAvailable();
});