many more improvements and cleanup

This commit is contained in:
Christoph Haas 2023-07-24 23:26:22 +02:00
parent 2a5b4fe31d
commit 984818c393
19 changed files with 179 additions and 83 deletions

View File

@ -2,7 +2,7 @@
<configuration default="false" name="wg-portal-migrate" type="GoApplicationRunConfiguration" factoryName="Go Application">
<module name="wg-portal" />
<working_directory value="$PROJECT_DIR$" />
<parameters value="-migrateFrom=test_source.db" />
<parameters value="-migrateFrom=wg_portal.db" />
<envs>
<env name="SESSION_SECRET" value="extremlybad" />
<env name="LOG_LEVEL" value="trace" />

View File

@ -4,6 +4,7 @@ import {userStore} from "@/stores/users";
import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import {freshUser} from "@/helpers/models";
const { t } = useI18n()
@ -30,34 +31,14 @@ const title = computed(() => {
return t("users.new")
})
const formData = ref(freshFormData())
function freshFormData() {
return {
Identifier: "",
Email: "",
Source: "db",
IsAdmin: false,
Firstname: "",
Lastname: "",
Phone: "",
Department: "",
Notes: "",
Password: "",
Disabled: false,
}
}
const formData = ref(freshUser())
// functions
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
if (!selectedUser.value) {
formData.value = freshFormData()
formData.value = freshUser()
} else { // fill existing userdata
formData.value.Identifier = selectedUser.value.Identifier
formData.value.Email = selectedUser.value.Email
@ -76,7 +57,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
)
function close() {
formData.value = freshFormData()
formData.value = freshUser()
emit('close')
}
@ -115,7 +96,7 @@ async function del() {
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset>
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">General</legend>
<div v-if="props.userId==='#NEW#'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.useredit.identifier') }}</label>
@ -131,7 +112,7 @@ async function del() {
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">Leave this field blank to keep current password.</small>
</div>
</fieldset>
<fieldset>
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">User Information</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.useredit.email') }}</label>
@ -169,9 +150,13 @@ async function del() {
<legend class="mt-4">State</legend>
<div class="form-check form-switch">
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label" >Disabled</label>
<label class="form-check-label" >Disabled (no WireGuard connection and no login possible)</label>
</div>
<div class="form-check form-switch">
<input v-model="formData.Locked" class="form-check-input" type="checkbox">
<label class="form-check-label" >Locked (no login possible, WireGuard connections still work)</label>
</div>
<div class="form-check form-switch" v-if="formData.Source==='db'">
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">Is Admin</label>
</div>
@ -180,7 +165,7 @@ async function del() {
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">Delete</button>
<button v-if="props.userId!=='#NEW#'&&formData.Source==='db'" class="btn btn-danger me-1" type="button" @click.prevent="del">Delete</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">Save</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">Discard</button>

View File

@ -66,7 +66,7 @@ function close() {
<div id="user" class="tab-pane fade active show">
<ul class="list-group list-group-flush">
<li class="list-group-item">
User Information:
<h4>User Information:</h4>
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr>
@ -89,11 +89,19 @@ function close() {
<td>{{ $t('users.label.department') }}:</td>
<td>{{selectedUser.Department}}</td>
</tr>
<tr v-if="selectedUser.Disabled">
<td>{{ $t('users.label.disabled') }}:</td>
<td>{{selectedUser.DisabledReason}}</td>
</tr>
<tr v-if="selectedUser.Locked">
<td>{{ $t('users.label.locked') }}:</td>
<td>{{selectedUser.LockedReason}}</td>
</tr>
</tbody>
</table>
</li>
<li class="list-group-item">
Notes:
<li class="list-group-item" v-if="selectedUser.Notes">
<h4>Notes:</h4>
<table class="table table-sm table-borderless device-status-table">
<tbody>
<tr><td>{{selectedUser.Notes}}</td></tr>

View File

@ -122,6 +122,29 @@ export function freshPeer() {
}
}
export function freshUser() {
return {
Identifier: "",
Email: "",
Source: "db",
IsAdmin: false,
Firstname: "",
Lastname: "",
Phone: "",
Department: "",
Notes: "",
Password: "",
Disabled: false,
DisabledReason: "",
Locked: false,
LockedReason: ""
}
}
export function freshStats() {
return {
IsConnected: false,

View File

@ -15,18 +15,6 @@ const viewedUserId = ref("")
onMounted(() => {
users.LoadUsers()
})
function editUser(user) {
if(user.Source === 'db') {
editUserId.value = user.Identifier
} else {
notify({
title: "Forbidden",
text: "You can not edit this user!",
type: 'error',
})
}
}
</script>
<template>
@ -62,6 +50,7 @@ function editUser(user) {
<th scope="col">
<input id="flexCheckDefault" class="form-check-input" title="Select all" type="checkbox" value="">
</th><!-- select -->
<th scope="col"></th><!-- status -->
<th scope="col">{{ $t('user.id') }}</th>
<th scope="col">{{ $t('user.email') }}</th>
<th scope="col">{{ $t('user.firstname') }}</th>
@ -77,6 +66,10 @@ function editUser(user) {
<th scope="row">
<input id="flexCheckDefault" class="form-check-input" type="checkbox" value="">
</th>
<td class="text-center">
<span v-if="user.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="user.DisabledReason"></i></span>
<span v-if="user.Locked" class="text-danger"><i class="fas fa-lock" :title="user.LockedReason"></i></span>
</td>
<td>{{user.Identifier}}</td>
<td>{{user.Email}}</td>
<td>{{user.Firstname}}</td>
@ -89,7 +82,7 @@ function editUser(user) {
</td>
<td class="text-center">
<a href="#" title="Show user" @click.prevent="viewedUserId=user.Identifier"><i class="fas fa-eye me-2"></i></a>
<a :class="{disabled:user.Source!=='db'}" href="#" title="Edit user" @click.prevent="editUser(user)"><i class="fas fa-cog me-2"></i></a>
<a href="#" title="Edit user" @click.prevent="editUserId=user.Identifier"><i class="fas fa-cog me-2"></i></a>
</td>
</tr>
</tbody>

View File

@ -263,8 +263,9 @@ func (r *SqlRepo) FindInterfaces(ctx context.Context, search string) ([]domain.I
}
func (r *SqlRepo) SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(in *domain.Interface) (*domain.Interface, error)) error {
userInfo := domain.GetUserInfo(ctx)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
in, err := r.getOrCreateInterface(tx, id)
in, err := r.getOrCreateInterface(userInfo, tx, id)
if err != nil {
return err // return any error will roll back
}
@ -274,7 +275,7 @@ func (r *SqlRepo) SaveInterface(ctx context.Context, id domain.InterfaceIdentifi
return err
}
err = r.upsertInterface(tx, in)
err = r.upsertInterface(userInfo, tx, in)
if err != nil {
return err
}
@ -289,12 +290,14 @@ func (r *SqlRepo) SaveInterface(ctx context.Context, id domain.InterfaceIdentifi
return nil
}
func (r *SqlRepo) getOrCreateInterface(tx *gorm.DB, id domain.InterfaceIdentifier) (*domain.Interface, error) {
func (r *SqlRepo) getOrCreateInterface(ui *domain.ContextUserInfo, tx *gorm.DB, id domain.InterfaceIdentifier) (*domain.Interface, error) {
var in domain.Interface
// interfaceDefaults will be applied to newly created interface records
interfaceDefaults := domain.Interface{
BaseModel: domain.BaseModel{
CreatedBy: ui.UserId(),
UpdatedBy: ui.UserId(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
@ -309,7 +312,10 @@ func (r *SqlRepo) getOrCreateInterface(tx *gorm.DB, id domain.InterfaceIdentifie
return &in, nil
}
func (r *SqlRepo) upsertInterface(tx *gorm.DB, in *domain.Interface) error {
func (r *SqlRepo) upsertInterface(ui *domain.ContextUserInfo, tx *gorm.DB, in *domain.Interface) error {
in.UpdatedBy = ui.UserId()
in.UpdatedAt = time.Now()
err := tx.Save(in).Error
if err != nil {
return err
@ -439,8 +445,9 @@ func (r *SqlRepo) FindUserPeers(ctx context.Context, id domain.UserIdentifier, s
}
func (r *SqlRepo) SavePeer(ctx context.Context, id domain.PeerIdentifier, updateFunc func(in *domain.Peer) (*domain.Peer, error)) error {
userInfo := domain.GetUserInfo(ctx)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
peer, err := r.getOrCreatePeer(tx, id)
peer, err := r.getOrCreatePeer(userInfo, tx, id)
if err != nil {
return err // return any error will roll back
}
@ -450,7 +457,7 @@ func (r *SqlRepo) SavePeer(ctx context.Context, id domain.PeerIdentifier, update
return err
}
err = r.upsertPeer(tx, peer)
err = r.upsertPeer(userInfo, tx, peer)
if err != nil {
return err
}
@ -465,12 +472,14 @@ func (r *SqlRepo) SavePeer(ctx context.Context, id domain.PeerIdentifier, update
return nil
}
func (r *SqlRepo) getOrCreatePeer(tx *gorm.DB, id domain.PeerIdentifier) (*domain.Peer, error) {
func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id domain.PeerIdentifier) (*domain.Peer, error) {
var peer domain.Peer
// interfaceDefaults will be applied to newly created interface records
interfaceDefaults := domain.Peer{
BaseModel: domain.BaseModel{
CreatedBy: ui.UserId(),
UpdatedBy: ui.UserId(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
@ -485,7 +494,10 @@ func (r *SqlRepo) getOrCreatePeer(tx *gorm.DB, id domain.PeerIdentifier) (*domai
return &peer, nil
}
func (r *SqlRepo) upsertPeer(tx *gorm.DB, peer *domain.Peer) error {
func (r *SqlRepo) upsertPeer(ui *domain.ContextUserInfo, tx *gorm.DB, peer *domain.Peer) error {
peer.UpdatedBy = ui.UserId()
peer.UpdatedAt = time.Now()
err := tx.Save(peer).Error
if err != nil {
return err
@ -626,7 +638,7 @@ func (r *SqlRepo) SaveUser(ctx context.Context, id domain.UserIdentifier, update
userInfo := domain.GetUserInfo(ctx)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := r.getOrCreateUser(string(userInfo.Id), tx, id)
user, err := r.getOrCreateUser(userInfo, tx, id)
if err != nil {
return err // return any error will roll back
}
@ -636,10 +648,7 @@ func (r *SqlRepo) SaveUser(ctx context.Context, id domain.UserIdentifier, update
return err
}
user.UpdatedAt = time.Now()
user.UpdatedBy = string(userInfo.Id)
err = r.upsertUser(tx, user)
err = r.upsertUser(userInfo, tx, user)
if err != nil {
return err
}
@ -663,14 +672,14 @@ func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) erro
return nil
}
func (r *SqlRepo) getOrCreateUser(creator string, tx *gorm.DB, id domain.UserIdentifier) (*domain.User, error) {
func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User
// userDefaults will be applied to newly created user records
userDefaults := domain.User{
BaseModel: domain.BaseModel{
CreatedBy: creator,
UpdatedBy: creator,
CreatedBy: ui.UserId(),
UpdatedBy: ui.UserId(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
@ -687,7 +696,10 @@ func (r *SqlRepo) getOrCreateUser(creator string, tx *gorm.DB, id domain.UserIde
return &user, nil
}
func (r *SqlRepo) upsertUser(tx *gorm.DB, user *domain.User) error {
func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error {
user.UpdatedBy = ui.UserId()
user.UpdatedAt = time.Now()
err := tx.Save(user).Error
if err != nil {
return err

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,7 @@
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
</script>
<script src="/api/v0/config/frontend.js"></script>
<script type="module" crossorigin src="/app/assets/index-4f7c99b3.js"></script>
<script type="module" crossorigin src="/app/assets/index-96214b1b.js"></script>
<link rel="stylesheet" href="/app/assets/index-7144f109.css">
</head>
<body class="d-flex flex-column min-vh-100">

View File

@ -22,6 +22,8 @@ type User struct {
Password string `json:"Password,omitempty"`
Disabled bool `json:"Disabled"` // if this field is set, the user is disabled
DisabledReason string `json:"DisabledReason"` // the reason why the user has been disabled
Locked bool `json:"Locked"` // if this field is set, the user is locked
LockedReason string `json:"LockedReason"` // the reason why the user has been locked
// Calculated
@ -43,6 +45,8 @@ func NewUser(src *domain.User) *User {
Password: "", // never fill password
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
Locked: src.IsLocked(),
LockedReason: src.LockedReason,
PeerCount: src.LinkedPeerCount,
}
@ -73,6 +77,8 @@ func NewDomainUser(src *User) *domain.User {
Password: domain.PrivateString(src.Password),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
Locked: nil, // set below
LockedReason: src.LockedReason,
LinkedPeerCount: src.PeerCount,
}
@ -80,5 +86,9 @@ func NewDomainUser(src *User) *domain.User {
res.Disabled = &now
}
if src.Locked {
res.Locked = &now
}
return res
}

View File

@ -159,6 +159,10 @@ func (a *Authenticator) IsUserValid(ctx context.Context, id domain.UserIdentifie
return false
}
if user.IsLocked() {
return false
}
return true
}
@ -193,6 +197,9 @@ func (a *Authenticator) passwordAuthentication(ctx context.Context, identifier d
userInDatabase = true
userSource = existingUser.Source
}
if userInDatabase && (existingUser.IsLocked() || existingUser.IsDisabled()) {
return nil, errors.New("user is locked")
}
if !userInDatabase || userSource == domain.UserSourceLdap {
// search user in ldap if registration is enabled
@ -313,6 +320,10 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
return nil, fmt.Errorf("unable to process user information: %w", err)
}
if user.IsLocked() || user.IsDisabled() {
return nil, errors.New("user is locked")
}
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
return user, nil

View File

@ -62,6 +62,8 @@ func migrateFromV1(cfg *config.Config, db *gorm.DB, source, typ string) error {
return fmt.Errorf("peer migration failed: %w", err)
}
logrus.Infof("Migrated V1 database with version %s, please restart WireGuard Portal", lastVersion.Version)
return nil
}
@ -121,6 +123,8 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
if err := newDb.Save(&newUser).Error; err != nil {
return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err)
}
logrus.Debugf(" - User %s migrated", newUser.Identifier)
}
return nil
@ -213,6 +217,8 @@ func migrateV1Interfaces(oldDb, newDb *gorm.DB) error {
if err := newDb.Save(&newInterface).Error; err != nil {
return fmt.Errorf("failed to migrate device %s: %w", oldDevice.DeviceName, err)
}
logrus.Debugf(" - Interface %s migrated", newInterface.Identifier)
}
return nil
@ -302,13 +308,15 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
ProviderName: "",
IsAdmin: false,
Locked: &now,
LockedReason: "migration dummy user",
LockedReason: domain.DisabledReasonMigrationDummy,
Notes: "created by migration from v1",
}
if err := newDb.Save(&user).Error; err != nil {
return fmt.Errorf("failed to migrate dummy user %s: %w", oldPeer.Email, err)
}
logrus.Debugf(" - Dummy User %s migrated", user.Identifier)
}
newPeer := domain.Peer{
BaseModel: domain.BaseModel{
@ -379,6 +387,8 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
if err := newDb.Save(&newPeer).Error; err != nil {
return fmt.Errorf("failed to migrate peer %s (%s): %w", oldPeer.Identifier, oldPeer.PublicKey, err)
}
logrus.Debugf(" - Peer %s migrated", newPeer.Identifier)
}
return nil

View File

@ -208,7 +208,7 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
return fmt.Errorf("insufficient permissions")
}
if err := old.EditAllowed(); err != nil {
if err := old.EditAllowed(new); err != nil {
return fmt.Errorf("no access: %w", err)
}
@ -224,6 +224,10 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
return fmt.Errorf("cannot disable own user")
}
if currentUser.Id == old.Identifier && new.IsLocked() {
return fmt.Errorf("cannot lock own user")
}
if old.Source != new.Source {
return fmt.Errorf("cannot change user source")
}
@ -264,7 +268,7 @@ func (m Manager) validateDeletion(ctx context.Context, del *domain.User) error {
return fmt.Errorf("insufficient permissions")
}
if err := del.EditAllowed(); err != nil {
if err := del.DeleteAllowed(); err != nil {
return fmt.Errorf("no access: %w", err)
}

View File

@ -22,13 +22,14 @@ func (PrivateString) String() string {
}
const (
DisabledReasonExpired = "expired"
DisabledReasonUserEdit = "user edit action"
DisabledReasonUserCreate = "user create action"
DisabledReasonAdminEdit = "admin edit action"
DisabledReasonAdminCreate = "admin create action"
DisabledReasonApiEdit = "api edit action"
DisabledReasonApiCreate = "api create action"
DisabledReasonLdapMissing = "missing in ldap"
DisabledReasonUserMissing = "missing user"
DisabledReasonExpired = "expired"
DisabledReasonUserEdit = "user edit action"
DisabledReasonUserCreate = "user create action"
DisabledReasonAdminEdit = "admin edit action"
DisabledReasonAdminCreate = "admin create action"
DisabledReasonApiEdit = "api edit action"
DisabledReasonApiCreate = "api create action"
DisabledReasonLdapMissing = "missing in ldap"
DisabledReasonUserMissing = "missing user"
DisabledReasonMigrationDummy = "migration dummy user"
)

View File

@ -2,6 +2,7 @@ package domain
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
)
@ -18,6 +19,14 @@ type ContextUserInfo struct {
IsAdmin bool
}
func (u *ContextUserInfo) String() string {
return fmt.Sprintf("%s|%t", u.Id, u.IsAdmin)
}
func (u *ContextUserInfo) UserId() string {
return string(u.Id)
}
func DefaultContextUserInfo() *ContextUserInfo {
return &ContextUserInfo{
Id: CtxUnknownUserId,

View File

@ -37,18 +37,25 @@ type User struct {
// 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
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
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
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) CanChangePassword() error {
if u.Source == UserSourceDatabase {
return nil
@ -57,12 +64,35 @@ func (u *User) CanChangePassword() error {
return errors.New("password change only allowed for database source")
}
func (u *User) EditAllowed() error {
func (u *User) EditAllowed(new *User) error {
if u.Source == UserSourceDatabase {
return nil
}
return errors.New("edit only allowed for database source")
// for users which are not database users, only the notes field and the disabled flag can be updated
updateOk := true
updateOk = 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 {
if u.Source == UserSourceDatabase {
return nil
}
return errors.New("delete only allowed for database source")
}
func (u *User) CheckPassword(password string) error {