From 78f51e838d804130d06df8ee3eab584474a768c1 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:16:51 +0800 Subject: [PATCH 01/12] feat: Add passkey authentication support with registration and login endpoints --- core/app/api/v2/auth.go | 72 ++- core/app/api/v2/setting.go | 80 +++ core/app/dto/auth.go | 5 + core/app/dto/setting.go | 13 + core/app/service/auth.go | 609 +++++++++++++++++- core/app/service/setting.go | 22 + core/go.mod | 1 + core/go.sum | 2 + core/i18n/lang/en.yaml | 8 +- core/i18n/lang/zh.yaml | 8 +- core/init/migration/migrate.go | 1 + core/init/migration/migrations/init.go | 31 + core/router/ro_base.go | 2 + core/router/ro_setting.go | 4 + frontend/src/api/index.ts | 7 +- frontend/src/api/interface/auth.ts | 6 + frontend/src/api/interface/setting.ts | 13 + frontend/src/api/modules/auth.ts | 8 + frontend/src/api/modules/setting.ts | 12 + frontend/src/lang/modules/zh.ts | 17 + frontend/src/utils/util.ts | 24 + .../src/views/login/components/login-form.vue | 97 ++- frontend/src/views/setting/safe/index.vue | 187 +++++- 23 files changed, 1220 insertions(+), 9 deletions(-) diff --git a/core/app/api/v2/auth.go b/core/app/api/v2/auth.go index c1b243d79dd2..6c3ca5825a8e 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,57 @@ 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 + } + 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 +216,9 @@ func (b *BaseApi) GetLoginSetting(c *gin.Context) { Theme: settingInfo.Theme, NeedCaptcha: needCaptcha, } + passkeyEnabled, passkeyConfigured := authService.PasskeyStatus(c) + res.PasskeyEnabled = passkeyEnabled + res.PasskeyConfigured = passkeyConfigured helper.SuccessWithData(c, res) } @@ -179,3 +234,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..78a7f37df465 100644 --- a/core/app/dto/setting.go +++ b/core/app/dto/setting.go @@ -250,4 +250,17 @@ type LoginSetting struct { PanelName string `json:"panelName"` Theme string `json:"theme"` NeedCaptcha bool `json:"needCaptcha"` + PasskeyEnabled bool `json:"passkeyEnabled"` + PasskeyConfigured bool `json:"passkeyConfigured"` +} + +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..6b4ad0d69b6e 100644 --- a/core/app/service/auth.go +++ b/core/app/service/auth.go @@ -2,16 +2,27 @@ package service import ( "crypto/hmac" + "crypto/rand" "encoding/base64" + "encoding/json" + "fmt" + "net" + "strconv" + "strings" + "sync" + "time" + "github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/repo" "github.com/1Panel-dev/1Panel/core/buserr" "github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/utils/common" "github.com/1Panel-dev/1Panel/core/utils/encrypt" "github.com/1Panel-dev/1Panel/core/utils/mfa" "github.com/gin-gonic/gin" - "strconv" + "github.com/go-webauthn/webauthn" + "github.com/go-webauthn/webauthn/protocol" ) type AuthService struct{} @@ -22,10 +33,112 @@ 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, bool) GetSecurityEntrance() string IsLogin(c *gin.Context) bool } +const ( + passkeyUserIDSettingKey = "PasskeyUserID" + passkeyCredentialSettingKey = "PasskeyCredentials" + passkeyMaxCredentials = 5 + passkeySessionTTL = 5 * time.Minute + passkeySessionKindLogin = "login" + passkeySessionKindRegister = "register" + passkeyCredentialNameDefault = "Passkey" +) + +var passkeySessions = newPasskeySessionStore() + +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) +} + +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"` +} + +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 +} + func NewIAuthService() IAuthService { return &AuthService{} } @@ -187,6 +300,500 @@ func (u *AuthService) IsLogin(c *gin.Context) bool { return err == nil } +func (u *AuthService) PasskeyStatus(c *gin.Context) (bool, 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 + } + + sessionID := passkeySessions.set(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 err := u.checkEntrance(entrance); err != nil { + return nil, "ErrEntrance", err + } + if sessionID == "" { + return nil, "ErrPasskeySession", buserr.New("ErrPasskeySession") + } + + config, msgKey, err := u.passkeyConfig(c) + if err != nil { + return nil, msgKey, err + } + + session, ok := passkeySessions.get(sessionID) + if !ok || session.Kind != 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) >= 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 + } + + sessionID := passkeySessions.set(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 + } + + session, ok := passkeySessions.get(sessionID) + if !ok || session.Kind != passkeySessionKindRegister { + return "ErrPasskeySession", buserr.New("ErrPasskeySession") + } + passkeySessions.delete(sessionID) + + records, err := loadPasskeyCredentialRecords() + if err != nil { + return "", err + } + if len(records) >= 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", passkeyCredentialNameDefault, time.Now().Format("20060102150405")) + } + + records = append(records, 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) { + records, err := loadPasskeyCredentialRecords() + if err != nil { + return false, err + } + return len(records) > 0, nil +} + +func (u *AuthService) passkeyUser(records []passkeyCredentialRecord, allowCreate bool) (*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 &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(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(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() ([]passkeyCredentialRecord, error) { + setting, err := settingRepo.Get(repo.WithByKey(passkeyCredentialSettingKey)) + if err != nil { + return nil, err + } + if setting.Value == "" { + return []passkeyCredentialRecord{}, nil + } + decrypted, err := encrypt.StringDecrypt(setting.Value) + if err != nil { + return nil, err + } + var records []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 []passkeyCredentialRecord) error { + if len(records) == 0 { + return settingRepo.Update(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(passkeyCredentialSettingKey, encrypted) +} + +func passkeyCredentialExists(records []passkeyCredentialRecord, credentialID []byte) bool { + encoded := base64.RawURLEncoding.EncodeToString(credentialID) + for _, record := range records { + if record.ID == encoded { + return true + } + } + return false +} + +func updatePasskeyCredentialRecord(records []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 + } + if bindDomain.Value != "" { + return origin, bindDomain.Value, nil + } + + rpID := stripHostPort(host) + if rpID == "" { + return "", "", fmt.Errorf("invalid relying party id") + } + return origin, rpID, nil +} + +func passkeyRequestHost(c *gin.Context) string { + host := c.GetHeader("X-Forwarded-Host") + if host == "" { + 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 { + proto := c.GetHeader("X-Forwarded-Proto") + if proto == "" { + if c.Request.TLS != nil { + return "https" + } + return "http" + } + if strings.Contains(proto, ",") { + proto = strings.TrimSpace(strings.Split(proto, ",")[0]) + } + return strings.ToLower(strings.TrimSpace(proto)) +} + +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 generatePasskeySessionID() string { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return common.RandStr(32) + } + return base64.RawURLEncoding.EncodeToString(raw) +} + 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..ef46a648e726 100644 --- a/core/app/service/setting.go +++ b/core/app/service/setting.go @@ -146,6 +146,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 +259,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 +279,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 +387,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 +528,16 @@ func (u *SettingService) UpdatePassword(c *gin.Context, old, new string) error { return nil } +func (u *SettingService) clearPasskeySettings() error { + if err := settingRepo.Update(passkeyUserIDSettingKey, ""); err != nil { + return err + } + if err := settingRepo.Update(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/go.mod b/core/go.mod index e05d5e0b2232..5e969b1e5fee 100644 --- a/core/go.mod +++ b/core/go.mod @@ -59,6 +59,7 @@ 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-webauthn/webauthn v0.15.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect diff --git a/core/go.sum b/core/go.sum index f12fe945cf42..13281636214b 100644 --- a/core/go.sum +++ b/core/go.sum @@ -75,6 +75,8 @@ 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-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/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= diff --git a/core/i18n/lang/en.yaml b/core/i18n/lang/en.yaml index 82be6e9f99da..c836ae2023e9 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 }}" @@ -248,4 +254,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/zh.yaml b/core/i18n/lang/zh.yaml index d80daf989257..bfaa41a9b4ff 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 }}" @@ -257,4 +263,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..dfb68d5279b0 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,30 @@ var InitSetting = &gormigrate.Migration{ }, } +var AddPasskeySetting = &gormigrate.Migration{ + ID: "20250910-add-passkey-setting", + Migrate: func(tx *gorm.DB) error { + if err := addSettingIfMissing(tx, "PasskeyUserID", ""); err != nil { + return err + } + if err := addSettingIfMissing(tx, "PasskeyCredentials", ""); err != nil { + return err + } + return nil + }, +} + +func addSettingIfMissing(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 +} + 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/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..f83b856d458c 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,7 @@ export namespace Login { theme: string; isOffLine: boolean; needCaptcha: boolean; + passkeyEnabled: boolean; + passkeyConfigured: 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/zh.ts b/frontend/src/lang/modules/zh.ts index 0d79427fd40f..d281152d2957 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -206,6 +206,9 @@ const message = { codeInput: '请输入 MFA 验证器的 6 位验证码', mfaTitle: 'MFA 认证', mfaCode: 'MFA 验证码', + passkey: 'Passkey 登录', + passkeyFailed: 'Passkey 登录失败,请重试', + passkeyNotSupported: '当前浏览器或环境不支持 Passkey', title: 'Linux 服务器运维管理面板', licenseHelper: '《飞致云社区软件许可协议》', errorAgree: '请点击同意社区软件许可协议', @@ -1933,6 +1936,20 @@ const message = { mfaInterval: '刷新时间(秒)', mfaTitleHelper: '用于区分不同 1Panel 主机,修改后请重新扫描或手动添加密钥信息!', mfaIntervalHelper: '修改刷新时间后,请重新扫描或手动添加密钥信息!', + 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 注册失败,请重试', 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..6a6c32c927c7 100644 --- a/frontend/src/views/login/components/login-form.vue +++ b/frontend/src/views/login/components/login-form.vue @@ -136,6 +136,11 @@ {{ $t('commons.button.login') }} + + + {{ $t('commons.login.passkey') }} + + {{ $t('commons.login.username') }}:demo {{ $t('commons.login.password') }}:1panel @@ -186,11 +191,11 @@