add webauthn (passkey) support

This commit is contained in:
Christoph Haas
2025-05-12 22:53:43 +02:00
parent 6a96925be7
commit 1394be2341
28 changed files with 1603 additions and 33 deletions

View File

@@ -2,9 +2,16 @@ package domain
import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
@@ -43,6 +50,10 @@ type User struct {
Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login (WireGuard peers still can connect)
LockedReason string // the reason why the user has been locked
// Passwordless authentication
WebAuthnId string `gorm:"column:webauthn_id"` // the webauthn id of the user, used for webauthn authentication
WebAuthnCredentialList []UserWebauthnCredential `gorm:"foreignKey:user_identifier"` // the webauthn credentials of the user, used for webauthn authentication
// API token for REST API access
ApiToken string `form:"api_token" binding:"omitempty"`
ApiTokenCreated *time.Time
@@ -157,3 +168,148 @@ func (u *User) CopyCalculatedAttributes(src *User) {
u.BaseModel = src.BaseModel
u.LinkedPeerCount = src.LinkedPeerCount
}
// region webauthn
func (u *User) WebAuthnID() []byte {
decodeString, err := base64.StdEncoding.DecodeString(u.WebAuthnId)
if err != nil {
return nil
}
return decodeString
}
func (u *User) GenerateWebAuthnId() {
randomUid1 := uuid.New().String() // 32 hex digits + 4 dashes
randomUid2 := uuid.New().String() // 32 hex digits + 4 dashes
webAuthnId := []byte(strings.ReplaceAll(fmt.Sprintf("%s%s", randomUid1, randomUid2), "-", "")) // 64 hex digits
u.WebAuthnId = base64.StdEncoding.EncodeToString(webAuthnId)
}
func (u *User) WebAuthnName() string {
return string(u.Identifier)
}
func (u *User) WebAuthnDisplayName() string {
var userName string
switch {
case u.Firstname != "" && u.Lastname != "":
userName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname)
case u.Firstname != "":
userName = u.Firstname
case u.Lastname != "":
userName = u.Lastname
default:
userName = string(u.Identifier)
}
return userName
}
func (u *User) WebAuthnCredentials() []webauthn.Credential {
credentials := make([]webauthn.Credential, len(u.WebAuthnCredentialList))
for i, cred := range u.WebAuthnCredentialList {
credential, err := cred.GetCredential()
if err != nil {
continue
}
credentials[i] = credential
}
return credentials
}
func (u *User) AddCredential(userId UserIdentifier, name string, credential webauthn.Credential) error {
cred, err := NewUserWebauthnCredential(userId, name, credential)
if err != nil {
return err
}
// Check if the credential already exists
for _, c := range u.WebAuthnCredentialList {
if c.GetCredentialId() == string(credential.ID) {
return errors.New("credential already exists")
}
}
u.WebAuthnCredentialList = append(u.WebAuthnCredentialList, cred)
return nil
}
func (u *User) UpdateCredential(credentialIdBase64, name string) error {
for i, c := range u.WebAuthnCredentialList {
if c.CredentialIdentifier == credentialIdBase64 {
u.WebAuthnCredentialList[i].DisplayName = name
return nil
}
}
return errors.New("credential not found")
}
func (u *User) RemoveCredential(credentialIdBase64 string) {
u.WebAuthnCredentialList = slices.DeleteFunc(u.WebAuthnCredentialList, func(e UserWebauthnCredential) bool {
return e.CredentialIdentifier == credentialIdBase64
})
}
type UserWebauthnCredential struct {
UserIdentifier string `gorm:"primaryKey;column:user_identifier"` // the user identifier
CredentialIdentifier string `gorm:"primaryKey;uniqueIndex;column:credential_identifier"` // base64 encoded credential id
CreatedAt time.Time `gorm:"column:created_at"` // the time when the credential was created
DisplayName string `gorm:"column:display_name"` // the display name of the credential
SerializedCredential string `gorm:"column:serialized_credential"` // JSON and base64 encoded credential
}
func NewUserWebauthnCredential(userIdentifier UserIdentifier, name string, credential webauthn.Credential) (
UserWebauthnCredential,
error,
) {
c := UserWebauthnCredential{
UserIdentifier: string(userIdentifier),
CreatedAt: time.Now(),
DisplayName: name,
CredentialIdentifier: base64.StdEncoding.EncodeToString(credential.ID),
}
err := c.SetCredential(credential)
if err != nil {
return c, err
}
return c, nil
}
func (c *UserWebauthnCredential) SetCredential(credential webauthn.Credential) error {
jsonData, err := json.Marshal(credential)
if err != nil {
return fmt.Errorf("failed to marshal credential: %w", err)
}
c.SerializedCredential = base64.StdEncoding.EncodeToString(jsonData)
return nil
}
func (c *UserWebauthnCredential) GetCredential() (webauthn.Credential, error) {
jsonData, err := base64.StdEncoding.DecodeString(c.SerializedCredential)
if err != nil {
return webauthn.Credential{}, fmt.Errorf("failed to decode base64 credential: %w", err)
}
var credential webauthn.Credential
if err := json.Unmarshal(jsonData, &credential); err != nil {
return webauthn.Credential{}, fmt.Errorf("failed to unmarshal credential: %w", err)
}
return credential, nil
}
func (c *UserWebauthnCredential) GetCredentialId() string {
decodeString, _ := base64.StdEncoding.DecodeString(c.CredentialIdentifier)
return string(decodeString)
}
// endregion webauthn