diff --git a/core/app/api/v2/auth.go b/core/app/api/v2/auth.go index c1b243d79dd2..4ce6087dd523 100644 --- a/core/app/api/v2/auth.go +++ b/core/app/api/v2/auth.go @@ -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) +} diff --git a/core/app/api/v2/setting.go b/core/app/api/v2/setting.go index c246fea24465..9131b2563de8 100644 --- a/core/app/api/v2/setting.go +++ b/core/app/api/v2/setting.go @@ -419,6 +419,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" { diff --git a/core/app/dto/auth.go b/core/app/dto/auth.go index 3ee2d56e2b99..9602671f8431 100644 --- a/core/app/dto/auth.go +++ b/core/app/dto/auth.go @@ -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"` diff --git a/core/app/dto/setting.go b/core/app/dto/setting.go index 1b1ea449a0ea..bd275b0ab807 100644 --- a/core/app/dto/setting.go +++ b/core/app/dto/setting.go @@ -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"` } diff --git a/core/app/service/auth.go b/core/app/service/auth.go index 1538503f238d..580793a05833 100644 --- a/core/app/service/auth.go +++ b/core/app/service/auth.go @@ -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")) diff --git a/core/app/service/setting.go b/core/app/service/setting.go index 7ab3a40ecce7..a418d424a7b9 100644 --- a/core/app/service/setting.go +++ b/core/app/service/setting.go @@ -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") diff --git a/core/cmd/server/cmd/reset.go b/core/cmd/server/cmd/reset.go index 451a57700136..de29fb14485e 100644 --- a/core/cmd/server/cmd/reset.go +++ b/core/cmd/server/cmd/reset.go @@ -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.") } diff --git a/core/go.mod b/core/go.mod index e05d5e0b2232..cb1560654abc 100644 --- a/core/go.mod +++ b/core/go.mod @@ -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 diff --git a/core/go.sum b/core/go.sum index f12fe945cf42..2b98aabd3ea6 100644 --- a/core/go.sum +++ b/core/go.sum @@ -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= diff --git a/core/i18n/lang/en.yaml b/core/i18n/lang/en.yaml index 82be6e9f99da..427dc49342e0 100644 --- a/core/i18n/lang/en.yaml +++ b/core/i18n/lang/en.yaml @@ -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 }}" @@ -190,6 +196,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..." @@ -248,4 +255,4 @@ ErrReqFailed: "{{.name}} request failed: {{ .err }}" #command Name: "Name" -Command: "Command" \ No newline at end of file +Command: "Command" diff --git a/core/i18n/lang/es-ES.yaml b/core/i18n/lang/es-ES.yaml index f38034ce8505..880b9ddddb56 100644 --- a/core/i18n/lang/es-ES.yaml +++ b/core/i18n/lang/es-ES.yaml @@ -190,6 +190,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 }}..." @@ -248,4 +249,4 @@ ErrReqFailed: "{{.name}} petición fallida: {{ .err }}" #command Name: "Nombre" -Command: "Comando" \ No newline at end of file +Command: "Comando" diff --git a/core/i18n/lang/ja.yaml b/core/i18n/lang/ja.yaml index 8a38064a8193..d71d44d43060 100644 --- a/core/i18n/lang/ja.yaml +++ b/core/i18n/lang/ja.yaml @@ -191,6 +191,7 @@ ResetHttps: "1Panel HTTPSログインをキャンセルします" ResetEntrance: "1パネルの安全な入り口をキャンセルします" ResetIPs: "1パネル認定IP制限をキャンセルします" ResetDomain: "1パネルドメインバインディングをキャンセルします" +ResetPasskey: "1Panel のパスキーをクリアします" RestoreCommands: "ロールバック1パネルサービスとデータ" RestoreNoSuchFile: "ロールバックに使用できるファイルはありません" RestoreStep1: "(1/5){{ .name }}ディレクトリからの1パネルサービスとデータの開始ロールバック..." @@ -249,4 +250,4 @@ ErrReqFailed: "{{.name}} リクエスト失敗: {{ .err }}" #command Name: "名前" -Command: "コマンド" \ No newline at end of file +Command: "コマンド" diff --git a/core/i18n/lang/ko.yaml b/core/i18n/lang/ko.yaml index 4d5a4b6b3989..392e172f2186 100644 --- a/core/i18n/lang/ko.yaml +++ b/core/i18n/lang/ko.yaml @@ -190,6 +190,7 @@ ResetHttps: "1Panel HTTPS 로그인 취소" ResetEntrance: "1Panel 보안 입구 취소" ResetIPs: "1Panel 허용 IP 제한 취소" ResetDomain: "1Panel 도메인 바인딩 취소" +ResetPasskey: "1Panel 패스키 초기화" RestoreCommands: "1Panel 서비스 및 데이터 복구" RestoreNoSuchFile: "복구 가능한 파일이 없습니다." RestoreStep1: "(1/5) {{ .name }} 디렉토리에서 1Panel 서비스 및 데이터 복구 시작..." @@ -248,4 +249,4 @@ ErrReqFailed: "{{.name}} 요청 실패: {{ .err }}" #command Name: "이름" -Command: "명령어" \ No newline at end of file +Command: "명령어" diff --git a/core/i18n/lang/ms.yaml b/core/i18n/lang/ms.yaml index 88671ea58a32..c1dd71672e6e 100644 --- a/core/i18n/lang/ms.yaml +++ b/core/i18n/lang/ms.yaml @@ -185,6 +185,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 }}..." @@ -238,4 +239,4 @@ ErrReqFailed: "{{.name}} permintaan gagal: {{ .err }}" #command Name: "Nama" -Command: "Arahan" \ No newline at end of file +Command: "Arahan" diff --git a/core/i18n/lang/pt-BR.yaml b/core/i18n/lang/pt-BR.yaml index 099768064384..c469c7a66f11 100644 --- a/core/i18n/lang/pt-BR.yaml +++ b/core/i18n/lang/pt-BR.yaml @@ -190,6 +190,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 }}..." @@ -248,4 +249,4 @@ ErrReqFailed: "{{.name}} solicitação falhou: {{ .err }}" #command Name: "Название" -Command: "Команда" \ No newline at end of file +Command: "Команда" diff --git a/core/i18n/lang/ru.yaml b/core/i18n/lang/ru.yaml index fa342f71cc80..b4a126eec461 100644 --- a/core/i18n/lang/ru.yaml +++ b/core/i18n/lang/ru.yaml @@ -189,6 +189,7 @@ ResetHttps: "Отменить вход в 1Panel через https" ResetEntrance: "Отменить безопасный вход 1Panel" ResetIPs: "Отменить ограничения авторизованных IP для 1Panel" ResetDomain: "Отменить привязку домена доступа 1Panel" +ResetPasskey: "Сбросить passkey 1Panel" RestoreCommands: "Откатить сервисы и данные 1Panel" RestoreNoSuchFile: "Нет доступных для отката файлов" RestoreStep1: "(1/5)Начинаем откат сервисов и данных 1Panel из каталога {{ .name }}..." @@ -247,4 +248,4 @@ ErrReqFailed: "{{.name}} запрос не удался: {{ .err }}" #command Name: "Название" -Command: "Команда" \ No newline at end of file +Command: "Команда" diff --git a/core/i18n/lang/tr.yaml b/core/i18n/lang/tr.yaml index e71b8e1b904a..59e41f2b34fa 100644 --- a/core/i18n/lang/tr.yaml +++ b/core/i18n/lang/tr.yaml @@ -189,6 +189,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..." @@ -247,4 +248,4 @@ ErrReqFailed: "{{.name}} istek başarısız: {{ .err }}" #command Name: "Ad" -Command: "Komut" \ No newline at end of file +Command: "Komut" diff --git a/core/i18n/lang/zh-Hant.yaml b/core/i18n/lang/zh-Hant.yaml index 964bed36c4c9..166260f7e9b3 100644 --- a/core/i18n/lang/zh-Hant.yaml +++ b/core/i18n/lang/zh-Hant.yaml @@ -192,6 +192,7 @@ ResetHttps: "取消 1Panel https 方式登入" ResetEntrance: "取消 1Panel 安全入口" ResetIPs: "取消 1Panel 授權 IP 限制" ResetDomain: "取消 1Panel 存取域名綁定" +ResetPasskey: "清空 1Panel 通行密鑰" RestoreCommands: "回滾 1Panel 服務及資料" RestoreNoSuchFile: "暫無可回滾文件" RestoreStep1: "(1/5)開始從 {{ .name }} 目錄回滾 1Panel 服務及資料... " @@ -258,4 +259,4 @@ ErrReqFailed: "{{.name}} 請求失敗: {{ .err }}" #command Name: "名稱" -Command: "指令" \ No newline at end of file +Command: "指令" diff --git a/core/i18n/lang/zh.yaml b/core/i18n/lang/zh.yaml index d80daf989257..f130f385d602 100644 --- a/core/i18n/lang/zh.yaml +++ b/core/i18n/lang/zh.yaml @@ -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 }}" @@ -191,6 +197,7 @@ ResetHttps: "取消 1Panel https 方式登录" ResetEntrance: "取消 1Panel 安全入口" ResetIPs: "取消 1Panel 授权 IP 限制" ResetDomain: "取消 1Panel 访问域名绑定" +ResetPasskey: "清空 1Panel 通行密钥" RestoreCommands: "回滚 1Panel 服务及数据" RestoreNoSuchFile: "暂无可回滚文件" RestoreStep1: "(1/5)开始从 {{ .name }} 目录回滚 1Panel 服务及数据... " @@ -257,4 +264,4 @@ ErrReqFailed: "{{.name}} 请求失败: {{ .err }}" #command Name: "名称" -Command: "命令" \ No newline at end of file +Command: "命令" diff --git a/core/init/migration/migrate.go b/core/init/migration/migrate.go index 9babcc807119..50be9260aec7 100644 --- a/core/init/migration/migrate.go +++ b/core/init/migration/migrate.go @@ -15,6 +15,7 @@ func Init() { migrations.InitHost, migrations.InitTerminalSetting, migrations.AddTaskDB, + migrations.AddPasskeySetting, migrations.AddXpackHideMenu, migrations.UpdateXpackHideMenu, migrations.UpdateOnedrive, diff --git a/core/init/migration/migrations/init.go b/core/init/migration/migrations/init.go index c48bbfcdb3f4..d2d6c23e1f82 100644 --- a/core/init/migration/migrations/init.go +++ b/core/init/migration/migrations/init.go @@ -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 { diff --git a/core/router/ro_base.go b/core/router/ro_base.go index 962390d07324..662c8cb17148 100644 --- a/core/router/ro_base.go +++ b/core/router/ro_base.go @@ -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) diff --git a/core/router/ro_setting.go b/core/router/ro_setting.go index 5e9deb7391cd..e4c62c5b33d7 100644 --- a/core/router/ro_setting.go +++ b/core/router/ro_setting.go @@ -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) diff --git a/core/utils/passkey/passkey_record.go b/core/utils/passkey/passkey_record.go new file mode 100644 index 000000000000..5a668d461c7d --- /dev/null +++ b/core/utils/passkey/passkey_record.go @@ -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"` +} diff --git a/core/utils/passkey/passkey_store.go b/core/utils/passkey/passkey_store.go new file mode 100644 index 000000000000..0e97485c780a --- /dev/null +++ b/core/utils/passkey/passkey_store.go @@ -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) +} diff --git a/core/utils/passkey/passkey_user.go b/core/utils/passkey/passkey_user.go new file mode 100644 index 000000000000..e98bc7fc11bc --- /dev/null +++ b/core/utils/passkey/passkey_user.go @@ -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 +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 094933cbce70..13ab6349d978 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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; } diff --git a/frontend/src/api/interface/auth.ts b/frontend/src/api/interface/auth.ts index 39d008cd60de..5f19dd404148 100644 --- a/frontend/src/api/interface/auth.ts +++ b/frontend/src/api/interface/auth.ts @@ -17,6 +17,10 @@ export namespace Login { token: string; mfaStatus: string; } + export interface PasskeyBeginResponse { + sessionId: string; + publicKey: Record; + } export interface ResCaptcha { imagePath: string; captchaID: string; @@ -36,5 +40,6 @@ export namespace Login { theme: string; isOffLine: boolean; needCaptcha: boolean; + passkeySetting: boolean; } } diff --git a/frontend/src/api/interface/setting.ts b/frontend/src/api/interface/setting.ts index 46ecf560e0cc..0f9214761e36 100644 --- a/frontend/src/api/interface/setting.ts +++ b/frontend/src/api/interface/setting.ts @@ -128,6 +128,19 @@ export namespace Setting { code: string; interval: string; } + export interface PasskeyRegisterRequest { + name: string; + } + export interface PasskeyBeginResponse { + sessionId: string; + publicKey: Record; + } + export interface PasskeyInfo { + id: string; + name: string; + createdAt: string; + lastUsedAt: string; + } export interface CommonDescription { id: string; type: string; diff --git a/frontend/src/api/modules/auth.ts b/frontend/src/api/modules/auth.ts index ca7deb324cdf..3f69d138ee5b 100644 --- a/frontend/src/api/modules/auth.ts +++ b/frontend/src/api/modules/auth.ts @@ -9,6 +9,14 @@ export const mfaLoginApi = (params: Login.MFALoginForm) => { return http.post(`/core/auth/mfalogin`, params); }; +export const passkeyBeginApi = () => { + return http.post(`/core/auth/passkey/begin`); +}; + +export const passkeyFinishApi = (params: Record, sessionId: string) => { + return http.post(`/core/auth/passkey/finish`, params, undefined, { 'Passkey-Session': sessionId }); +}; + export const getCaptcha = () => { return http.get(`/core/auth/captcha`); }; diff --git a/frontend/src/api/modules/setting.ts b/frontend/src/api/modules/setting.ts index 052a0597b01c..3fd346f238ce 100644 --- a/frontend/src/api/modules/setting.ts +++ b/frontend/src/api/modules/setting.ts @@ -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(`/core/settings/passkey/register/begin`, param); +}; +export const passkeyRegisterFinish = (param: Record, sessionId: string) => { + return http.post(`/core/settings/passkey/register/finish`, param, undefined, { 'Passkey-Session': sessionId }); +}; +export const passkeyList = () => { + return http.get>(`/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(`/core/settings/apps/store/config${params}`); diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 1554f319d335..49fd23cfb5b3 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -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 « Community License Agreement »', + 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', @@ -1924,6 +1929,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: diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 30a97dcb5c42..90d59baef09e 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -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 « Acuerdo de Licencia Comunitaria »', + 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', @@ -1939,6 +1943,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: diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index b8a4445958ea..0c289275b37c 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -185,6 +185,7 @@ const message = { login: { username: 'ユーザー名', password: 'パスワード', + passkey: 'パスキーでログイン', welcome: 'ようこそ、ユーザー名とパスワードを入力してログインしてください!', errorAuthInfo: '入力したユーザー名またはパスワードは間違っています。再入力してください!', errorMfaInfo: '認証情報が誤っていない、もう一度やり直してください!', @@ -206,6 +207,9 @@ const message = { agreeTitle: '合意', agreeContent: 'あなたの正当な権利と利益をよりよく保護するために、次の契約とラコを読んで同意してください。コミュニティライセンス契約&raquo;', + passkeyFailed: 'パスキーでのログインに失敗しました。再試行してください', + passkeyNotSupported: '現在のブラウザまたは環境はパスキーに対応していません', + passkeyToPassword: 'パスキーが使えませんか?パスワードでログインしてください', }, rule: { username: 'ユーザー名を入力します', @@ -1851,6 +1855,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: diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index a4b7f1865ba5..5f23038efcbc 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -184,6 +184,7 @@ const message = { login: { username: '사용자 이름', password: '비밀번호', + passkey: '패스키 로그인', welcome: '다시 오신 것을 환영합니다. 사용자 이름과 비밀번호를 입력하여 로그인하세요!', errorAuthInfo: '입력한 사용자 이름 또는 비밀번호가 잘못되었습니다. 다시 입력해주세요!', errorMfaInfo: '인증 정보가 잘못되었습니다. 다시 시도해주세요!', @@ -205,6 +206,9 @@ const message = { agreeTitle: '동의', agreeContent: '귀하의 합법적인 권리와 이익을 보다 잘 보호하기 위해, 다음 « 커뮤니티 라이선스 계약 »을 읽고 동의해주세요.', + passkeyFailed: '패스키 로그인에 실패했습니다. 다시 시도하세요', + passkeyNotSupported: '현재 브라우저 또는 환경에서 패스키를 지원하지 않습니다', + passkeyToPassword: '패스키 사용에 문제가 있나요? 비밀번호로 로그인하세요', }, rule: { username: '사용자 이름을 입력하세요', @@ -1827,6 +1831,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: '서비스 수신 주소나 프로토콜 변경은 서비스 불가 상태를 초래할 수 있습니다. 계속하시겠습니까?', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index bad1f451dd4b..2317f7436e9f 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -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 « Perjanjian Lesen Komuniti »', + 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', @@ -1905,6 +1909,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: diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 5c98130c030c..f3de383e4fb5 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -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 « Acordo de Licença Comunitária »', + 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', @@ -1893,6 +1897,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: diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 95f5e271114e..b3b39293a021 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -185,6 +185,7 @@ const message = { login: { username: 'Имя пользователя', password: 'Пароль', + passkey: 'Вход по passkey', welcome: 'Добро пожаловать, введите имя пользователя и пароль для входа!', errorAuthInfo: 'Введенное имя пользователя или пароль неверны, пожалуйста, введите заново!', errorMfaInfo: 'Неверная информация аутентификации, попробуйте еще раз!', @@ -206,6 +207,9 @@ const message = { agreeTitle: 'Соглашение', agreeContent: 'Для лучшей защиты ваших законных прав и интересов, пожалуйста, прочитайте и согласитесь со следующим соглашением « Лицензионное соглашение сообщества »', + passkeyFailed: 'Вход по passkey не удался, попробуйте снова', + passkeyNotSupported: 'Текущий браузер или среда не поддерживает passkey', + passkeyToPassword: 'Не получается использовать passkey? Войдите по паролю', }, rule: { username: 'Введите имя пользователя', @@ -1894,6 +1898,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: diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 3436632c2129..be3b0a472446 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -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 « Topluluk Lisans Sözleşmesi »', + 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', @@ -1946,6 +1950,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: diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index a9d7b0aac1ff..1afeb9b15794 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -192,6 +192,7 @@ const message = { login: { username: '使用者名稱', password: '密碼', + passkey: '通行密鑰登入', welcome: '歡迎回來,請輸入使用者名稱和密碼登入!', errorAuthInfo: '您輸入的使用者名稱或密碼不正確,請重新輸入!', errorMfaInfo: '錯誤的驗證資訊,請重試!', @@ -213,6 +214,9 @@ const message = { agreeContent: '為了更好的保障您的合法權益,請您閱讀並同意以下協議 « 飛致雲社區軟體許可協議 »', logout: '退出登入', + passkeyFailed: '通行密鑰登入失敗,請重試', + passkeyNotSupported: '目前瀏覽器或環境不支援通行密鑰', + passkeyToPassword: '無法使用通行密鑰? 使用帳號密碼登入', }, rule: { username: '請輸入使用者名稱', @@ -1887,6 +1891,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: '修改服務監聽地址或協議可能導致服務不可用,是否繼續?', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 0d79427fd40f..cd205c0fe321 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -206,6 +206,10 @@ const message = { codeInput: '请输入 MFA 验证器的 6 位验证码', mfaTitle: 'MFA 认证', mfaCode: 'MFA 验证码', + passkey: '使用通行密钥', + passkeyFailed: '通行密钥登录失败,请重试', + passkeyNotSupported: '当前浏览器或环境不支持通行密钥', + passkeyToPassword: '无法使用通行密钥? 使用账号密码登录', title: 'Linux 服务器运维管理面板', licenseHelper: '《飞致云社区软件许可协议》', errorAgree: '请点击同意社区软件许可协议', @@ -1933,6 +1937,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 服务,需要重启面板才能生效,是否继续?', diff --git a/frontend/src/utils/util.ts b/frontend/src/utils/util.ts index f1629e2cede3..1452cfc5bddf 100644 --- a/frontend/src/utils/util.ts +++ b/frontend/src/utils/util.ts @@ -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(/=+$/, ''); +} diff --git a/frontend/src/views/login/components/login-form.vue b/frontend/src/views/login/components/login-form.vue index 9cc826e1cbd2..013bc9b40cea 100644 --- a/frontend/src/views/login/components/login-form.vue +++ b/frontend/src/views/login/components/login-form.vue @@ -39,39 +39,93 @@ -
- -
-
{{ $t('commons.button.login') }}
-
- - - {{ dropdownText }} - - - +
+
+
{{ $t('commons.button.login') }}
+
+ + + {{ dropdownText }} + + + + + + +
+
+
+ + + + + + {{ $t('commons.login.passkeyToPassword') }} + + + + + + + +
+
+
+
+
{{ $t('commons.button.login') }}
+
+ + + {{ dropdownText }} + + + + + +
+
+
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): 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 = { + 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); } diff --git a/frontend/src/views/setting/safe/index.vue b/frontend/src/views/setting/safe/index.vue index 1f6f6e0d9c06..19e77b83221e 100644 --- a/frontend/src/views/setting/safe/index.vue +++ b/frontend/src/views/setting/safe/index.vue @@ -164,6 +164,18 @@ {{ $t('setting.mfaHelper') }} + + + + {{ $t('setting.passkeyManage') }} + + + {{ passkeyHint }} + + @@ -179,6 +191,38 @@ + + +
+ + + + +
+ + {{ $t('setting.passkeyAdd') }} + + {{ passkeyCountText }} +
+
+
+ + + + + + + + + + +
@@ -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([]); +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): 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(); });