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
5 changes: 5 additions & 0 deletions docs/documentation/configuration/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ advanced:
route_table_offset: 20000
api_admin_only: true
limit_additional_user_peers: 0
two_factor_lifetime: 0

database:
debug: false
Expand Down Expand Up @@ -339,6 +340,10 @@ Additional or more specialized configuration options for logging and interface c
- **Environment Variable:** `WG_PORTAL_ADVANCED_LIMIT_ADDITIONAL_USER_PEERS`
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.

### `two_factor_lifetime`
- **Default:** `0`
- **Environment Variable:** `WG_PORTAL_ADVANCED_TWO_FACTOR_LIFETIME`
- **Description:** Require a login after the set interval, otherwise the peer will be disabled. `0` disables this feature.
---

## Database
Expand Down
14 changes: 14 additions & 0 deletions docs/documentation/usage/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,17 @@ All groups that are listed in the `memberof` attribute of the user will be check

## User Synchronization

## Two-factor-like authentication

When enabled, this feature requires users to periodically authenticate via the web UI to keep their WireGuard peers active, adding a second verification layer beyond just possessing the WireGuard keys.

To enable this feature, simply set `two_factor_lifetime` parameter in config->advanced (or via WG_PORTAL_ADVANCED_TWO_FACTOR_LIFETIME env variable) to the desired lifetime (0 disables the feature):

```yaml
advanced:
two_factor_lifetime: 12h
```

The above example will require users to log-out / log-in from WG-portal every 12 hours to keep their peers active.

Note: due to the standalone nature of the Wireguard protocol, I couldn't find a way to communicate to users that their peer has expired. Most tools will consider the connection as "established" even though the remote-peer has been deleted.
8 changes: 6 additions & 2 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ onMounted(async () => {
}
}

if (!wasLoggedIn && window.location.href != '/app/#/login') {
window.location.href = '/app/#/login';
return
}

console.log("WireGuard Portal ready!");
})

Expand Down Expand Up @@ -199,14 +204,13 @@ const userDisplayName = computed(() => {
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('en')"><span class="fi fi-us"></span> English</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('fr')"><span class="fi fi-fr"></span> Français</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('ko')"><span class="fi fi-kr"></span> 한국어</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('ko')"><span class="fi fi-kr"></span> 한국어</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('pt')"><span class="fi fi-pt"></span> Português</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('ru')"><span class="fi fi-ru"></span> Русский</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('es')"><span class="fi fi-es"></span> Español</a>

</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion internal/app/api/v0/model/models_peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"github.com/h44z/wg-portal/internal/domain"
)

const ExpiryDateTimeLayout = "\"2006-01-02\""
const ExpiryDateTimeLayout = "\"2006-01-02T15:04:05\""

type ExpiryDate struct {
*time.Time
Expand Down
4 changes: 2 additions & 2 deletions internal/app/api/v1/models/models_peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"github.com/h44z/wg-portal/internal/domain"
)

const ExpiryDateTimeLayout = "2006-01-02"
const ExpiryDateTimeLayout = "2006-01-02T15:04:05"

// Peer represents a WireGuard peer entry.
type Peer struct {
Expand All @@ -24,7 +24,7 @@ type Peer struct {
// DisabledReason is the reason why the peer has been disabled.
DisabledReason string `json:"DisabledReason" binding:"required_if=Disabled true" example:"This is a reason why the peer has been disabled."`
// ExpiresAt is the expiry date of the peer in YYYY-MM-DD format. An expired peer is not able to connect.
ExpiresAt string `json:"ExpiresAt,omitempty" binding:"omitempty,datetime=2006-01-02"`
ExpiresAt string `json:"ExpiresAt,omitempty" binding:"omitempty,datetime=2006-01-02T15:04:05"`
// Notes is a note field for peers.
Notes string `json:"Notes" example:"This is a note for the peer."`

Expand Down
52 changes: 45 additions & 7 deletions internal/app/wireguard/wireguard.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,6 @@ func (m Manager) handleUserCreationEvent(user domain.User) {
}

func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) {
if !m.cfg.Core.CreateDefaultPeer {
return
}

_, loaded := m.userLockMap.LoadOrStore(userId, "login")
if loaded {
return // another goroutine is already handling this user
Expand All @@ -134,6 +130,41 @@ func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) {
return
}

for i, peer := range userPeers {
if m.cfg.Advanced.TwoFactorLifetime == time.Duration(0) {
break
}
expiration := time.Now().Add(m.cfg.Advanced.TwoFactorLifetime)

err := m.db.SavePeer(context.Background(), peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
peer.CopyCalculatedAttributes(p)
peer.ExpiresAt = &expiration
if peer.DisabledReason == domain.DisabledReasonExpired {
peer.Disabled = nil
peer.DisabledReason = ""
userPeers[i].DisabledReason = domain.DisabledReasonPendingActivation
userPeers[i].Disabled = nil
userPeers[i].ExpiresAt = &expiration
}
return &peer, nil
})

if err != nil {
slog.Error("failed to set expiration of peers for users",
"user", userId,
"error", err)
return
}
slog.Debug("Setting expiration time for peer", "peer", peer.Identifier, "user", userId)
}

adminCtx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
m.checkExpiredPeers(adminCtx, userPeers, true)

if !m.cfg.Core.CreateDefaultPeer {
return
}

if len(userPeers) > 0 {
return // user already has peers, skip creation
}
Expand Down Expand Up @@ -296,17 +327,17 @@ func (m Manager) runExpiredPeersCheck(ctx context.Context) {
continue
}

m.checkExpiredPeers(ctx, peers)
m.checkExpiredPeers(ctx, peers, false)
}
}
}

func (m Manager) checkExpiredPeers(ctx context.Context, peers []domain.Peer) {
func (m Manager) checkExpiredPeers(ctx context.Context, peers []domain.Peer, forceEnable bool) {
now := time.Now()

for _, peer := range peers {
if peer.IsExpired() && !peer.IsDisabled() {
slog.Info("peer has expired, disabling", "peer", peer.Identifier)
slog.Debug("peer has expired, disabling", "peer", peer.Identifier)

peer.Disabled = &now
peer.DisabledReason = domain.DisabledReasonExpired
Expand All @@ -315,6 +346,13 @@ func (m Manager) checkExpiredPeers(ctx context.Context, peers []domain.Peer) {
if err != nil {
slog.Error("failed to update expired peer", "peer", peer.Identifier, "error", err)
}
} else if forceEnable && !peer.IsDisabled() && peer.DisabledReason == domain.DisabledReasonPendingActivation {
slog.Debug("user has logged in, enabling", "peer", peer.Identifier)
peer.Disabled = nil
peer.DisabledReason = ""
if _, err := m.UpdatePeer(ctx, &peer); err != nil {
slog.Error("failed to update unexpired peer", "peer", peer.Identifier, "error", err)
}
}
}
}
4 changes: 3 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ type Config struct {
ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"`
RulePrioOffset int `yaml:"rule_prio_offset"`
RouteTableOffset int `yaml:"route_table_offset"`
ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API
ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API
LimitAdditionalUserPeers int `yaml:"limit_additional_user_peers"`
TwoFactorLifetime time.Duration `yaml:"two_factor_lifetime"` // if set, all peers will expire after duration and require a login into UI
} `yaml:"advanced"`

Backend Backend `yaml:"backend"`
Expand Down Expand Up @@ -174,6 +175,7 @@ func defaultConfig() *Config {
cfg.Advanced.RouteTableOffset = getEnvInt("WG_PORTAL_ADVANCED_ROUTE_TABLE_OFFSET", 20000)
cfg.Advanced.ApiAdminOnly = getEnvBool("WG_PORTAL_ADVANCED_API_ADMIN_ONLY", true)
cfg.Advanced.LimitAdditionalUserPeers = getEnvInt("WG_PORTAL_ADVANCED_LIMIT_ADDITIONAL_USER_PEERS", 0)
cfg.Advanced.TwoFactorLifetime = getEnvDuration("WG_PORTAL_ADVANCED_TWO_FACTOR_LIFETIME", 0)

cfg.Statistics.UsePingChecks = getEnvBool("WG_PORTAL_STATISTICS_USE_PING_CHECKS", true)
cfg.Statistics.PingCheckWorkers = getEnvInt("WG_PORTAL_STATISTICS_PING_CHECK_WORKERS", 10)
Expand Down
1 change: 1 addition & 0 deletions internal/domain/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const (
DisabledReasonLdapMissing = "missing in ldap"
DisabledReasonMigrationDummy = "migration dummy user"
DisabledReasonInterfaceMissing = "missing WireGuard interface"
DisabledReasonPendingActivation = "pending activation"

LockedReasonAdmin = "locked by admin"
LockedReasonApi = "locked by admin"
Expand Down
4 changes: 4 additions & 0 deletions internal/domain/peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ func (p *Peer) OverwriteUserEditableFields(userPeer *Peer, cfg *config.Config) {
p.Interface.Mtu = userPeer.Interface.Mtu
p.PersistentKeepalive = userPeer.PersistentKeepalive
p.ExpiresAt = userPeer.ExpiresAt
if cfg.Advanced.TwoFactorLifetime > time.Duration(0) {
t := time.Now().Add(cfg.Advanced.TwoFactorLifetime)
p.ExpiresAt = &t
}
p.Disabled = userPeer.Disabled
p.DisabledReason = userPeer.DisabledReason
}
Expand Down
Loading