mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-10 21:21:13 +08:00
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:
1 parent
1025cd3535
commit
d742c7d333
44 files changed
+1599
-55
No files matched your search
+73
-1
@@ -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)
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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: "コマンド"
|
||||
@@ -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: "명령어"
|
||||
@@ -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"
|
||||
@@ -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: "Команда"
|
||||
@@ -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: "Команда"
|
||||
@@ -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"
|
||||
@@ -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: "指令"
|
||||
@@ -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: "命令"
|
||||
@@ -15,6 +15,7 @@ func Init() {
|
||||
migrations.InitHost,
|
||||
migrations.InitTerminalSetting,
|
||||
migrations.AddTaskDB,
|
||||
migrations.AddPasskeySetting,
|
||||
migrations.AddXpackHideMenu,
|
||||
migrations.UpdateXpackHideMenu,
|
||||
migrations.UpdateOnedrive,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 « <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Community License Agreement </a> »',
|
||||
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:
|
||||
|
||||
@@ -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 « <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Acuerdo de Licencia Comunitaria </a> »',
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -184,6 +184,7 @@ const message = {
|
||||
login: {
|
||||
username: '사용자 이름',
|
||||
password: '비밀번호',
|
||||
passkey: '패스키 로그인',
|
||||
welcome: '다시 오신 것을 환영합니다. 사용자 이름과 비밀번호를 입력하여 로그인하세요!',
|
||||
errorAuthInfo: '입력한 사용자 이름 또는 비밀번호가 잘못되었습니다. 다시 입력해주세요!',
|
||||
errorMfaInfo: '인증 정보가 잘못되었습니다. 다시 시도해주세요!',
|
||||
@@ -205,6 +206,9 @@ const message = {
|
||||
agreeTitle: '동의',
|
||||
agreeContent:
|
||||
'귀하의 합법적인 권리와 이익을 보다 잘 보호하기 위해, 다음 « <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" >커뮤니티 라이선스 계약</a> »을 읽고 동의해주세요.',
|
||||
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: '서비스 수신 주소나 프로토콜 변경은 서비스 불가 상태를 초래할 수 있습니다. 계속하시겠습니까?',
|
||||
|
||||
@@ -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 « <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Perjanjian Lesen Komuniti </a> »',
|
||||
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:
|
||||
|
||||
@@ -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 « <a href="https://www.fit2cloud.com/legal/licenses.html" target="_blank">Acordo de Licença Comunitária</a> »',
|
||||
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:
|
||||
|
||||
@@ -185,6 +185,7 @@ const message = {
|
||||
login: {
|
||||
username: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
passkey: 'Вход по passkey',
|
||||
welcome: 'Добро пожаловать, введите имя пользователя и пароль для входа!',
|
||||
errorAuthInfo: 'Введенное имя пользователя или пароль неверны, пожалуйста, введите заново!',
|
||||
errorMfaInfo: 'Неверная информация аутентификации, попробуйте еще раз!',
|
||||
@@ -206,6 +207,9 @@ const message = {
|
||||
agreeTitle: 'Соглашение',
|
||||
agreeContent:
|
||||
'Для лучшей защиты ваших законных прав и интересов, пожалуйста, прочитайте и согласитесь со следующим соглашением « <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Лицензионное соглашение сообщества </a> »',
|
||||
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:
|
||||
|
||||
@@ -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 « <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Topluluk Lisans Sözleşmesi </a> »',
|
||||
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:
|
||||
|
||||
@@ -192,6 +192,7 @@ const message = {
|
||||
login: {
|
||||
username: '使用者名稱',
|
||||
password: '密碼',
|
||||
passkey: '通行密鑰登入',
|
||||
welcome: '歡迎回來,請輸入使用者名稱和密碼登入!',
|
||||
errorAuthInfo: '您輸入的使用者名稱或密碼不正確,請重新輸入!',
|
||||
errorMfaInfo: '錯誤的驗證資訊,請重試!',
|
||||
@@ -213,6 +214,9 @@ const message = {
|
||||
agreeContent:
|
||||
'為了更好的保障您的合法權益,請您閱讀並同意以下協議 « <a href="https://www.fit2cloud.com/legal/licenses.html" target="_blank"> 飛致雲社區軟體許可協議 </a> »',
|
||||
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: '修改服務監聽地址或協議可能導致服務不可用,是否繼續?',
|
||||
|
||||
@@ -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 服务,需要重启面板才能生效,是否继续?',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user