Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions core/app/api/v2/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"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/mfa"
"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -98,6 +99,14 @@ func (b *BaseApi) UpdateSetting(c *gin.Context) {
return
}
}
if req.Key == "PasskeyTrustedProxies" {
value, err := normalizePasskeyTrustedProxies(req.Value)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Value = value
}

if err := settingService.Update(req.Key, req.Value); err != nil {
helper.InternalServer(c, err)
Expand Down Expand Up @@ -666,3 +675,26 @@ func checkEntrancePattern(val string) bool {
}
return true
}

func normalizePasskeyTrustedProxies(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", nil
}
lines := strings.Split(value, "\n")
validLines := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
validLines = append(validLines, line)
}
if len(validLines) == 0 {
return "", nil
}
normalized := strings.Join(validLines, "\n")
if _, err := common.HandleIPList(normalized); err != nil {
return "", err
}
return normalized, nil
}
1 change: 1 addition & 0 deletions core/app/dto/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type SettingInfo struct {
SSL string `json:"ssl"`
SSLType string `json:"sslType"`
BindDomain string `json:"bindDomain"`
PasskeyTrustedProxies string `json:"passkeyTrustedProxies"`
AllowIPs string `json:"allowIPs"`
SecurityEntrance string `json:"securityEntrance"`
DashboardMemoVisible string `json:"dashboardMemoVisible"`
Expand Down
114 changes: 107 additions & 7 deletions core/app/service/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"strconv"
Expand All @@ -22,6 +23,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
)

type AuthService struct{}
Expand Down Expand Up @@ -454,13 +456,6 @@ func (u *AuthService) PasskeyDelete(id string) error {
}

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
}

Expand Down Expand Up @@ -679,9 +674,114 @@ func passkeyRequestScheme(c *gin.Context) string {
if c.Request.TLS != nil {
return "https"
}
if !passkeyIsFromTrustedProxy(c) {
return "http"
}
if proto := passkeyForwardedProto(c.GetHeader("Forwarded")); proto != "" {
return proto
}
if proto := passkeyXForwardedProto(c.GetHeader("X-Forwarded-Proto")); proto != "" {
return proto
}
return "http"
}

func passkeyIsFromTrustedProxy(c *gin.Context) bool {
remoteIP := passkeyRemoteIP(c.Request.RemoteAddr)
if remoteIP == nil {
return false
}
proxies, err := loadPasskeyTrustedProxies()
if err != nil {
global.LOG.Errorf("load passkey trusted proxies failed, err: %v", err)
return false
}
for _, cidr := range proxies {
if cidr.Contains(remoteIP) {
return true
}
}
return false
}

func passkeyRemoteIP(remoteAddr string) net.IP {
if host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddr)); err == nil {
return net.ParseIP(host)
}
return net.ParseIP(strings.TrimSpace(remoteAddr))
}

func loadPasskeyTrustedProxies() ([]*net.IPNet, error) {
setting, err := settingRepo.Get(repo.WithByKey("PasskeyTrustedProxies"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return parsePasskeyTrustedProxies("127.0.0.1\n::1")
}
return nil, err
}
return parsePasskeyTrustedProxies(setting.Value)
}

func parsePasskeyTrustedProxies(value string) ([]*net.IPNet, error) {
lines := strings.Split(strings.TrimSpace(value), "\n")
if len(lines) == 0 {
return []*net.IPNet{}, nil
}
cidrs := make([]*net.IPNet, 0, len(lines))
for _, line := range lines {
item := strings.TrimSpace(line)
if item == "" {
continue
}
if ip := net.ParseIP(item); ip != nil {
if ip.To4() != nil {
item += "/32"
} else {
item += "/128"
}
}
_, ipNet, err := net.ParseCIDR(item)
if err != nil {
return nil, err
}
cidrs = append(cidrs, ipNet)
}
return cidrs, nil
}

func passkeyForwardedProto(value string) string {
if value == "" {
return ""
}
for _, item := range strings.Split(value, ",") {
for _, part := range strings.Split(item, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
if !strings.EqualFold(strings.TrimSpace(kv[0]), "proto") {
continue
}
proto := strings.ToLower(strings.Trim(strings.TrimSpace(kv[1]), `"`))
if proto == "http" || proto == "https" {
return proto
}
}
}
return ""
}

func passkeyXForwardedProto(value string) string {
if value == "" {
return ""
}
proto := strings.ToLower(strings.TrimSpace(strings.Split(value, ",")[0]))
if proto == "http" || proto == "https" {
return proto
}
return ""
}

func stripHostPort(hostport string) string {
if hostport == "" {
return hostport
Expand Down
6 changes: 0 additions & 6 deletions core/app/service/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,6 @@ 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() {
Expand Down Expand Up @@ -391,9 +388,6 @@ 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()
}

Expand Down
1 change: 1 addition & 0 deletions core/init/migration/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func Init() {
migrations.InitTerminalSetting,
migrations.AddTaskDB,
migrations.AddPasskeySetting,
migrations.AddPasskeyTrustedProxySetting,
migrations.AddXpackHideMenu,
migrations.UpdateXpackHideMenu,
migrations.UpdateOnedrive,
Expand Down
20 changes: 20 additions & 0 deletions core/init/migration/migrations/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ var InitSetting = &gormigrate.Migration{
if err := tx.Create(&model.Setting{Key: "PasskeyCredentials", Value: ""}).Error; err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "PasskeyTrustedProxies", Value: "127.0.0.1\n::1"}).Error; err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "SystemVersion", Value: global.CONF.Base.Version}).Error; err != nil {
return err
}
Expand Down Expand Up @@ -212,6 +215,23 @@ var AddPasskeySetting = &gormigrate.Migration{
},
}

var AddPasskeyTrustedProxySetting = &gormigrate.Migration{
ID: "20260210-add-passkey-trusted-proxy-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
}
return addSettingsIfMissing(tx, "PasskeyTrustedProxies", "127.0.0.1\n::1")
},
}

var InitTerminalSetting = &gormigrate.Migration{
ID: "20240814-init-terminal-setting",
Migrate: func(tx *gorm.DB) error {
Expand Down
43 changes: 36 additions & 7 deletions core/utils/passkey/passkey_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import (
)

const (
PasskeyUserIDSettingKey = "PasskeyUserID"
PasskeyCredentialSettingKey = "PasskeyCredentials"
PasskeyMaxCredentials = 5
PasskeySessionTTL = 5 * time.Minute
PasskeySessionKindLogin = "login"
PasskeySessionKindRegister = "register"
PasskeyCredentialNameDefault = "Passkey"
PasskeyUserIDSettingKey = "PasskeyUserID"
PasskeyCredentialSettingKey = "PasskeyCredentials"
PasskeyMaxCredentials = 5
PasskeySessionTTL = 5 * time.Minute
PasskeySessionKindLogin = "login"
PasskeySessionKindRegister = "register"
PasskeyCredentialNameDefault = "Passkey"
PasskeySessionStoreMaxEntries = 1024
)

var passkeySessions = newPasskeySessionStore()
Expand Down Expand Up @@ -46,6 +47,11 @@ func (s *passkeySessionStore) Set(kind, name string, session webauthn.SessionDat
s.mu.Lock()
defer s.mu.Unlock()

s.cleanupExpiredLocked()
if len(s.items) >= PasskeySessionStoreMaxEntries {
s.removeOldestLocked()
}

sessionID := generatePasskeySessionID()
s.items[sessionID] = passkeySession{
Kind: kind,
Expand Down Expand Up @@ -77,6 +83,29 @@ func (s *passkeySessionStore) Delete(sessionID string) {
delete(s.items, sessionID)
}

func (s *passkeySessionStore) cleanupExpiredLocked() {
now := time.Now()
for id, item := range s.items {
if now.After(item.ExpiresAt) {
delete(s.items, id)
}
}
}

func (s *passkeySessionStore) removeOldestLocked() {
var oldestID string
var oldestTime time.Time
for id, item := range s.items {
if oldestID == "" || item.ExpiresAt.Before(oldestTime) {
oldestID = id
oldestTime = item.ExpiresAt
}
}
if oldestID != "" {
delete(s.items, oldestID)
}
}

func generatePasskeySessionID() string {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/interface/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export namespace Setting {
sslType: string;
allowIPs: string;
bindDomain: string;
passkeyTrustedProxies: string;
securityEntrance: string;
dashboardMemoVisible: string;
dashboardSimpleNodeVisible: string;
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/lang/modules/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ const message = {
'In order to better protect your legitimate rights and interests, please read and agree to the following agreement &laquo; <a href = "https://www.fit2cloud.com/legal/licenses.html" target = "_blank" > Community License Agreement </a> &raquo;',
passkeyFailed: 'Passkey login failed, please try again',
passkeyNotSupported:
'Current browser or environment does not support passkeys, please confirm you have bound the domain name and enabled the panel SSL, and used a trusted certificate when accessing',
'Current browser or environment does not support passkeys, please confirm you have bound a domain name and are accessing through HTTPS',
passkeyToPassword: 'Have trouble using a passkey? Use password instead',
},
rule: {
Expand Down Expand Up @@ -1979,8 +1979,12 @@ const message = {
safe: 'Security',
passkey: 'Passkey',
passkeyManage: 'Manage',
passkeyKeyManagement: 'Key Management',
passkeyHelper: 'For quick login, up to 5 passkeys can be bound',
passkeyRequireSSL: 'Enable SSL with a trusted certificate and bind a domain name before using passkeys',
passkeyRequireSSL: 'Passkeys require a bound domain name and HTTPS access',
passkeyTrustedProxies: 'Trusted proxies',
passkeyTrustedProxiesHelper:
'Only requests from these IP/CIDR sources will trust Forwarded and X-Forwarded-Proto when determining HTTPS',
passkeyNotSupported: 'Current browser or environment does not support passkeys',
passkeyCount: 'Bound {0}/{1}',
passkeyName: 'Name',
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lang/modules/es-es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1992,8 +1992,12 @@ const message = {
safe: 'Seguridad',
passkey: 'Passkey',
passkeyManage: 'Administrar',
passkeyKeyManagement: 'Gestión de claves',
passkeyHelper: 'Para inicio rápido, se pueden vincular hasta 5 passkeys',
passkeyRequireSSL: 'Activa SSL con un certificado de confianza y vincula un dominio para usar passkeys',
passkeyRequireSSL: 'Los passkeys requieren un dominio vinculado y acceso por HTTPS',
passkeyTrustedProxies: 'Proxies confiables',
passkeyTrustedProxiesHelper:
'Solo las solicitudes desde estas IP/CIDR confiarán en Forwarded y X-Forwarded-Proto para detectar HTTPS',
passkeyNotSupported: 'El navegador o entorno actual no admite passkeys',
passkeyCount: 'Vinculadas {0}/{1}',
passkeyName: 'Nombre',
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/lang/modules/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1904,9 +1904,13 @@ const message = {
safe: '安全',
passkey: 'パスキー',
passkeyManage: '管理',
passkeyKeyManagement: 'キー管理',
passkeyHelper: '素早くログインするため、最大 5 個のパスキーを登録できます',
passkeyRequireSSL: '信頼された証明書で SSL を有効化し、ドメインをバインドしてからパスキーを使用できます',
passkeyNotSupported: '現在のブラウザまたは環境はパスキーに対応していません',
passkeyRequireSSL: 'Passkey はドメインをバインドして HTTPS でアクセスする必要があります',
passkeyTrustedProxies: '信頼済みプロキシ',
passkeyTrustedProxiesHelper:
'以下の IP/CIDR からのリクエストのみが Forwarded/X-Forwarded-Proto を HTTPS 判定に使います',
passkeyNotSupported: 'ブラウザまたは環境がパスキー非対応です。ドメインと HTTPS を確認してください',
passkeyCount: '登録済み {0}/{1}',
passkeyName: '名称',
passkeyNameHelper: 'デバイスを区別する名称を入力してください',
Expand Down
Loading
Loading