mirror of
https://github.com/h44z/wg-portal.git
synced 2025-06-27 16:57:01 +00:00
316 lines
9.2 KiB
Go
316 lines
9.2 KiB
Go
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"
|
|
)
|
|
|
|
const (
|
|
UserSourceLdap UserSource = "ldap" // LDAP / ActiveDirectory
|
|
UserSourceDatabase UserSource = "db" // sqlite / mysql database
|
|
UserSourceOauth UserSource = "oauth" // oauth / open id connect
|
|
)
|
|
|
|
type UserIdentifier string
|
|
|
|
type UserSource string
|
|
|
|
// User is the user model that gets linked to peer entries, by default an empty user model with only the email address is created
|
|
type User struct {
|
|
BaseModel
|
|
|
|
// required fields
|
|
Identifier UserIdentifier `gorm:"primaryKey;column:identifier"`
|
|
Email string `form:"email" binding:"required,email"`
|
|
Source UserSource
|
|
ProviderName string
|
|
IsAdmin bool
|
|
|
|
// optional fields
|
|
Firstname string `form:"firstname" binding:"omitempty"`
|
|
Lastname string `form:"lastname" binding:"omitempty"`
|
|
Phone string `form:"phone" binding:"omitempty"`
|
|
Department string `form:"department" binding:"omitempty"`
|
|
Notes string `form:"notes" binding:"omitempty"`
|
|
|
|
// optional, integrated password authentication
|
|
Password PrivateString `form:"password" binding:"omitempty"`
|
|
Disabled *time.Time `gorm:"index;column:disabled"` // if this field is set, the user is disabled (WireGuard peers are disabled as well)
|
|
DisabledReason string // the reason why the user has been disabled
|
|
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
|
|
|
|
LinkedPeerCount int `gorm:"-"`
|
|
}
|
|
|
|
// IsDisabled returns true if the user is disabled. In such a case,
|
|
// no login is possible and WireGuard peers associated with the user are disabled.
|
|
func (u *User) IsDisabled() bool {
|
|
return u.Disabled != nil
|
|
}
|
|
|
|
// IsLocked returns true if the user is locked. In such a case, no login is possible, WireGuard connections still work.
|
|
func (u *User) IsLocked() bool {
|
|
return u.Locked != nil
|
|
}
|
|
|
|
func (u *User) IsApiEnabled() bool {
|
|
if u.ApiToken != "" {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (u *User) CanChangePassword() error {
|
|
if u.Source == UserSourceDatabase {
|
|
return nil
|
|
}
|
|
|
|
return errors.New("password change only allowed for database source")
|
|
}
|
|
|
|
func (u *User) EditAllowed(new *User) error {
|
|
if u.Source == UserSourceDatabase {
|
|
return nil
|
|
}
|
|
|
|
// for users which are not database users, only the notes field and the disabled flag can be updated
|
|
updateOk := u.Identifier == new.Identifier
|
|
updateOk = updateOk && u.Source == new.Source
|
|
updateOk = updateOk && u.IsAdmin == new.IsAdmin
|
|
updateOk = updateOk && u.Email == new.Email
|
|
updateOk = updateOk && u.Firstname == new.Firstname
|
|
updateOk = updateOk && u.Lastname == new.Lastname
|
|
updateOk = updateOk && u.Phone == new.Phone
|
|
updateOk = updateOk && u.Department == new.Department
|
|
|
|
if !updateOk {
|
|
return errors.New("edit only allowed for database source")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *User) DeleteAllowed() error {
|
|
return nil // all users can be deleted, OAuth and LDAP users might still be recreated
|
|
}
|
|
|
|
func (u *User) CheckPassword(password string) error {
|
|
if u.Source != UserSourceDatabase {
|
|
return errors.New("invalid user source")
|
|
}
|
|
|
|
if u.IsDisabled() {
|
|
return errors.New("user disabled")
|
|
}
|
|
|
|
if u.Password == "" {
|
|
return errors.New("empty user password")
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
|
|
return errors.New("wrong password")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *User) CheckApiToken(token string) error {
|
|
if !u.IsApiEnabled() {
|
|
return errors.New("api access disabled")
|
|
}
|
|
|
|
if res := subtle.ConstantTimeCompare([]byte(u.ApiToken), []byte(token)); res != 1 {
|
|
return errors.New("wrong token")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *User) HashPassword() error {
|
|
if u.Password == "" {
|
|
return nil // nothing to hash
|
|
}
|
|
|
|
if _, err := bcrypt.Cost([]byte(u.Password)); err == nil {
|
|
return nil // password already hashed
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.Password = PrivateString(hash)
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|