Files
1Panel/core/app/auth/passkey.go
T

599 lines
17 KiB
Go

package auth
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"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"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/init/session/psession"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
"github.com/1Panel-dev/1Panel/core/utils/passkey"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
)
func EvaluatePasskeyStatus(c *gin.Context, configured func() (bool, error)) bool {
enabled, err := PasskeyEnabled(c)
if err != nil {
global.LOG.Errorf("passkey enabled check failed, err: %v", err)
enabled = false
}
configuredOK, err := configured()
if err != nil {
global.LOG.Errorf("passkey config check failed, err: %v", err)
configuredOK = false
}
return enabled && configuredOK
}
func PasskeyStatus(c *gin.Context) bool {
return EvaluatePasskeyStatus(c, communityPasskeyConfigured)
}
func PasskeyBeginLogin(c *gin.Context, entrance string) (*dto.PasskeyBeginResponse, string, error) {
if err := CheckEntrance(entrance); err != nil {
return nil, "ErrEntrance", err
}
config, msgKey, err := PasskeyConfig(c)
if err != nil {
return nil, msgKey, err
}
records, err := loadCommunityPasskeyCredentialRecords()
if err != nil {
return nil, "", err
}
if len(records) == 0 {
return nil, "ErrPasskeyNotConfigured", buserr.New("ErrPasskeyNotConfigured")
}
user, err := communityPasskeyUser(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 := passkey.GetPasskeySessionStore().Set(passkey.PasskeySessionKindLogin, "", *sessionData)
return &dto.PasskeyBeginResponse{SessionID: sessionID, PublicKey: assertion.Response}, "", nil
}
func PasskeyFinishLogin(c *gin.Context, sessionID, entrance string) (*dto.UserLoginInfo, string, error) {
if sessionID == "" {
return nil, "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
if err := CheckEntrance(entrance); err != nil {
return nil, "ErrEntrance", err
}
config, msgKey, err := PasskeyConfig(c)
if err != nil {
return nil, msgKey, err
}
sessionStore := passkey.GetPasskeySessionStore()
session, ok := sessionStore.Get(sessionID)
if !ok || session.Kind != passkey.PasskeySessionKindLogin {
return nil, "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
sessionStore.Delete(sessionID)
records, err := loadCommunityPasskeyCredentialRecords()
if err != nil {
return nil, "", err
}
if len(records) == 0 {
return nil, "ErrPasskeyNotConfigured", buserr.New("ErrPasskeyNotConfigured")
}
user, err := communityPasskeyUser(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 := saveCommunityPasskeyCredentialRecords(records); err != nil {
return nil, "", err
}
userSetting, err := repo.NewISettingRepo().Get(repo.WithByKey("UserName"))
if err != nil {
return nil, "", err
}
res, err := GenerateSession(c, psession.SessionUser{ID: psession.SuperAdminSessionUserID, Name: userSetting.Value, Role: "ADMIN"})
if err != nil {
return nil, "", err
}
if entrance != "" {
SetSecurityEntranceCookie(c, entrance)
}
return res, "", nil
}
func PasskeyBeginRegister(c *gin.Context, name string) (*dto.PasskeyBeginResponse, string, error) {
config, msgKey, err := PasskeyConfig(c)
if err != nil {
return nil, msgKey, err
}
records, err := loadCommunityPasskeyCredentialRecords()
if err != nil {
return nil, "", err
}
if len(records) >= passkey.PasskeyMaxCredentials {
return nil, "ErrPasskeyLimit", buserr.New("ErrPasskeyLimit")
}
user, err := communityPasskeyUser(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 := passkey.GetPasskeySessionStore().Set(passkey.PasskeySessionKindRegister, strings.TrimSpace(name), *sessionData)
return &dto.PasskeyBeginResponse{SessionID: sessionID, PublicKey: creation.Response}, "", nil
}
func PasskeyFinishRegister(c *gin.Context, sessionID string) (string, error) {
if sessionID == "" {
return "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
config, msgKey, err := PasskeyConfig(c)
if err != nil {
return msgKey, err
}
sessionStore := passkey.GetPasskeySessionStore()
session, ok := sessionStore.Get(sessionID)
if !ok || session.Kind != passkey.PasskeySessionKindRegister {
return "ErrPasskeySession", buserr.New("ErrPasskeySession")
}
sessionStore.Delete(sessionID)
records, err := loadCommunityPasskeyCredentialRecords()
if err != nil {
return "", err
}
if len(records) >= passkey.PasskeyMaxCredentials {
return "ErrPasskeyLimit", buserr.New("ErrPasskeyLimit")
}
user, err := communityPasskeyUser(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 := saveCommunityPasskeyCredentialRecords(records); err != nil {
return "", err
}
return "", nil
}
func PasskeyList() ([]dto.PasskeyInfo, error) {
records, err := loadCommunityPasskeyCredentialRecords()
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 PasskeyDelete(id string) error {
records, err := loadCommunityPasskeyCredentialRecords()
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 saveCommunityPasskeyCredentialRecords(records)
}
func ClearPasskeys() error {
settingRepo := repo.NewISettingRepo()
if err := settingRepo.Update(passkey.PasskeyUserIDSettingKey, ""); err != nil {
return err
}
return settingRepo.Update(passkey.PasskeyCredentialSettingKey, "")
}
func communityPasskeyConfigured() (bool, error) {
bindDomain, err := repo.NewISettingRepo().Get(repo.WithByKey("BindDomain"))
if err != nil {
return false, err
}
if strings.TrimSpace(bindDomain.Value) == "" {
return false, nil
}
records, err := loadCommunityPasskeyCredentialRecords()
if err != nil {
return false, err
}
return len(records) > 0, nil
}
func communityPasskeyUser(records []passkey.PasskeyCredentialRecord, allowCreate bool) (*passkey.PasskeyUser, error) {
settingRepo := repo.NewISettingRepo()
storedUserID, err := settingRepo.Get(repo.WithByKey(passkey.PasskeyUserIDSettingKey))
if err != nil {
return nil, err
}
rawUserID, encodedUserID, err := GeneratePasskeyUserID(storedUserID.Value, allowCreate)
if err != nil {
return nil, err
}
if storedUserID.Value == "" && encodedUserID != "" {
if err := settingRepo.Update(passkey.PasskeyUserIDSettingKey, encodedUserID); err != nil {
return nil, err
}
}
nameSetting, err := settingRepo.Get(repo.WithByKey("UserName"))
if err != nil {
return nil, err
}
return NewPasskeyUser(rawUserID, nameSetting.Value, records), nil
}
func loadCommunityPasskeyCredentialRecords() ([]passkey.PasskeyCredentialRecord, error) {
setting, err := repo.NewISettingRepo().Get(repo.WithByKey(passkey.PasskeyCredentialSettingKey))
if err != nil {
return nil, err
}
return LoadPasskeyCredentialRecords(setting.Value)
}
func saveCommunityPasskeyCredentialRecords(records []passkey.PasskeyCredentialRecord) error {
encoded, err := SavePasskeyCredentialRecords(records)
if err != nil {
return err
}
return repo.NewISettingRepo().Update(passkey.PasskeyCredentialSettingKey, encoded)
}
func PasskeyEnabled(c *gin.Context) (bool, error) {
return strings.EqualFold(PasskeyRequestScheme(c), "https"), nil
}
func PasskeyConfig(c *gin.Context) (*webauthn.Config, string, error) {
enabled, err := 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 := repo.NewISettingRepo().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 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 := repo.NewISettingRepo().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 NewPasskeyUser(userID []byte, name string, records []passkey.PasskeyCredentialRecord) *passkey.PasskeyUser {
credentials := make([]webauthn.Credential, len(records))
for i, record := range records {
credentials[i] = record.Credential
}
return &passkey.PasskeyUser{
ID: userID,
Name: name,
DisplayName: name,
Credentials: credentials,
}
}
func GeneratePasskeyUserID(encoded string, allowCreate bool) ([]byte, string, error) {
if encoded == "" {
if !allowCreate {
return nil, "", buserr.New("ErrPasskeyNotConfigured")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, "", err
}
return raw, base64.RawURLEncoding.EncodeToString(raw), nil
}
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, "", err
}
return raw, encoded, nil
}
func LoadPasskeyCredentialRecords(encryptedValue string) ([]passkey.PasskeyCredentialRecord, error) {
if encryptedValue == "" {
return []passkey.PasskeyCredentialRecord{}, nil
}
decrypted, err := encrypt.StringDecrypt(encryptedValue)
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) (string, error) {
if len(records) == 0 {
return "", nil
}
copyRecords := make([]passkey.PasskeyCredentialRecord, len(records))
copy(copyRecords, records)
for i := range copyRecords {
copyRecords[i].FlagsValue = CredentialFlagsValue(copyRecords[i].Credential.Flags)
}
raw, err := json.Marshal(copyRecords)
if err != nil {
return "", err
}
return encrypt.StringEncrypt(string(raw))
}
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 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 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 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 := repo.NewISettingRepo().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(value, "\n")
proxies := make([]*net.IPNet, 0, len(lines))
for _, line := range lines {
entry := strings.TrimSpace(line)
if entry == "" {
continue
}
if !strings.Contains(entry, "/") {
if strings.Contains(entry, ":") {
entry += "/128"
} else {
entry += "/32"
}
}
_, cidr, err := net.ParseCIDR(entry)
if err != nil {
return nil, err
}
proxies = append(proxies, cidr)
}
return proxies, nil
}
func passkeyForwardedProto(forwarded string) string {
for _, part := range strings.Split(forwarded, ";") {
item := strings.TrimSpace(part)
if len(item) < 6 || !strings.EqualFold(item[:6], "proto=") {
continue
}
proto := strings.Trim(strings.TrimSpace(item[6:]), `"`)
if strings.EqualFold(proto, "https") {
return "https"
}
if strings.EqualFold(proto, "http") {
return "http"
}
}
return ""
}
func passkeyXForwardedProto(forwarded string) string {
if forwarded == "" {
return ""
}
proto := strings.TrimSpace(strings.Split(forwarded, ",")[0])
if strings.EqualFold(proto, "https") {
return "https"
}
if strings.EqualFold(proto, "http") {
return "http"
}
return ""
}
func stripHostPort(host string) string {
host = strings.TrimSpace(host)
if host == "" {
return ""
}
if strings.HasPrefix(host, "[") {
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return strings.Trim(parsedHost, "[]")
}
}
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return strings.Trim(host, "[]")
}