diff --git a/docs/access_control.md b/docs/access_control.md index 264a75248dbd81625c0a82376aa725b349910893..1a3c07fcd899e876074181c5746fcf9419d382d6 100644 --- a/docs/access_control.md +++ b/docs/access_control.md @@ -17,6 +17,8 @@ The table below lists all potential capabilities associated to a user: | USER_CREATE | Can create a user | | USER_DESTROY | Can destroy a user | | USER_SET_CAPS | Can change a user's capabilities | +| USER_UNLOCK | Can unlock a user's account | +| USER_RESETPWD | Can reset a user's password | | USER_LIST | Can list all users | | VM_CREATE | Can create a VM | | VM_DESTROY_ANY | Can destoy **ANY** VM | diff --git a/docs/rest_api.md b/docs/rest_api.md index 43e344652101da2a1189769e1199b3b80f1a52a1..d7086082cac5a4eb2c7faef413c09c1471039d98 100644 --- a/docs/rest_api.md +++ b/docs/rest_api.md @@ -2,69 +2,71 @@ ### Login and version management -| Route | Description | Method | Parameters | -|--- |--- |--- |--- | -| `/login` | login (return access token) | POST | email,pwd | -| `/token/refresh` | obtain a new access token | GET | | -| `/version` | obtain version number | GET | | +| Route | Description | Method | Parameters | Output | +|--- |--- |--- |--- |--- | +| `/login` | login (return access token) | POST | email,pwd | common.params.Login | +| `/token/refresh` | obtain a new access token | GET | | common.params.Token | +| `/version` | obtain version number | GET | | version number | ### User management -| Route | Description | Method | Parameters | Req. user cap. | -|--- |--- |--- |--- |--- | -| `/users` | create a user | POST | email,firstname,lastname,pwd,caps | `USER_CREATE` | -| `/users/{email}` | delete a user | DELETE | | `USER_DESTROY` | -| `/users/{email}/caps` | set caps for a user | PUT | caps | `USER_SET_CAPS` | -| `/users/pwd` | set current user's pwd | PUT | pwd | | -| `/users` | list users | GET | | `USER_LIST` | -| `/users/{email}` | list a user | GET | | `USER_LIST` | -| `/users/whoami` | list current user | GET | | | +| Route | Description | Method | Parameters | Req. user cap. | Output | +|--- |--- |--- |--- |--- |--- | +| `/users` | create a user | POST | email,firstname,lastname,pwd,caps | `USER_CREATE` | | +| `/users/{email}` | delete a user | DELETE | | `USER_DESTROY` | | +| `/users/{email}/caps` | set caps for a user | PUT | caps | `USER_SET_CAPS` | | +| `/users/{email}/unlock` | unlock a user | PUT | | `USER_UNLOCK` | | +| `/users/{email}/resetpwd` | reset a user's pwd | PUT | | `USER_RESETPWD` | new pwd | +| `/users/pwd` | set current user's pwd | PUT | pwd | | | +| `/users` | list users | GET | | `USER_LIST` | | +| `/users/{email}` | list a user | GET | | `USER_LIST` | | +| `/users/whoami` | list current user | GET | | | | - Open question: shall we forbid the deletion of a user if they still own templates or VMs? ### VM management -| Route | Description | Method | Parameters | Req. user cap. | Op. | Req. VM access cap. | -|--- |--- |--- |--- |--- |--- |--- | -| `/vms` | returns VMs that can be listed | GET | | `VM_LIST_ANY` | OR | `VM_LIST` | -| `/vms/{id}` | returns the specified VM | GET | | `VM_LIST_ANY` | OR | `VM_LIST` | -| `/vms/start` | returns VMs that can be started | GET | | `VM_START_ANY` | OR | `VM_START` | -| `/vms/attach` | returns VM creds info for VMs that can be attached to | GET | | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | -| `/vms/{id}/attach` | returns VM creds info for the specified VM | GET | | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | -| `/vms/stop` | returns VMs that can be killed/shutdown | GET | | `VM_STOP_ANY` | OR | `VM_STOP` | -| `/vms/reboot` | returns VMs that can be rebooted | GET | | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | -| `/vms/edit` | returns VMs that can be edited | GET | | `VM_EDIT_ANY` | OR | `VM_EDIT` | -| `/vms/editaccess` | returns VMs that can have their access changed | GET | | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | -| `/vms/del` | returns VMs that can be deleted | GET | | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | -| `/vms/exportdir` | returns VMs that can have a dir downloaded | GET | | `VM_READFS_ANY` | OR | `VM_READFS` | -| `/vms/importfiles` | returns VMs allowing files upload | GET | | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | +| Route | Description | Method | Parameters | Req. user cap. | Op. | Req. VM access cap. | Output | +|--- |--- |--- |--- |--- |--- |--- |--- | +| `/vms` | returns VMs that can be listed | GET | | `VM_LIST_ANY` | OR | `VM_LIST` | | +| `/vms/{id}` | returns the specified VM | GET | | `VM_LIST_ANY` | OR | `VM_LIST` | | +| `/vms/start` | returns VMs that can be started | GET | | `VM_START_ANY` | OR | `VM_START` | | +| `/vms/attach` | returns VM creds info for VMs that can be attached to | GET | | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | | +| `/vms/{id}/attach` | returns VM creds info for the specified VM | GET | | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | | +| `/vms/stop` | returns VMs that can be killed/shutdown | GET | | `VM_STOP_ANY` | OR | `VM_STOP` | | +| `/vms/reboot` | returns VMs that can be rebooted | GET | | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | | +| `/vms/edit` | returns VMs that can be edited | GET | | `VM_EDIT_ANY` | OR | `VM_EDIT` | | +| `/vms/editaccess` | returns VMs that can have their access changed | GET | | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | | +| `/vms/del` | returns VMs that can be deleted | GET | | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | | +| `/vms/exportdir` | returns VMs that can have a dir downloaded | GET | | `VM_READFS_ANY` | OR | `VM_READFS` | | +| `/vms/importfiles` | returns VMs allowing files upload | GET | | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | | -| Route | Description | Method | Parameters | Req. user cap. | Op. | Req. VM access cap. | -|--- |--- |--- |--- |--- |--- |--- | -| `/vms` | create a VM | POST | name,cpus,ram,nic,usb,template | `VM_CREATE` | | | -| `/vms/{id}` | delete a VM | DELETE | | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | -| `/vms/{id}` | edit a VM | PUT | name,cpus,ram,nic,usb | `VM_EDIT_ANY` | OR | `VM_EDIT` | -| `/vms/{id}/start` | start a VM | PUT | | `VM_START_ANY` | OR | `VM_START` | -| `/vms/{id}/startwithcreds` | start a VM with credentials | PUT | port,pwd | `VM_START_ANY` | OR | `VM_START` | -| `/vms/{id}/stop` | kill a VM | PUT | | `VM_STOP_ANY` | OR | `VM_STOP` | -| `/vms/{id}/reboot` | reboot a VM | PUT | | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | -| `/vms/{id}/shutdown` | gracefully shutdown a VM | PUT | | `VM_STOP_ANY` | OR | `VM_STOP` | -| `/vms/{id}/access/{email}` | set VM access for a user | PUT | caps | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | -| `/vms/{id}/access/{email}` | del VM access for a user | DELETE | | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | -| `/vms/{id}/exportdir` | download a VM's dir | GET | dir | `VM_READFS_ANY` | OR | `VM_READFS` | -| `/vms/{id}/importfiles` | upload files into a VM's dir | POST | files (.tar archive),dir | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | +| Route | Description | Method | Parameters | Req. user cap. | Op. | Req. VM access cap. | Output | +|--- |--- |--- |--- |--- |--- |--- |--- | +| `/vms` | create a VM | POST | name,cpus,ram,nic,usb,template | `VM_CREATE` | | | | +| `/vms/{id}` | delete a VM | DELETE | | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | | +| `/vms/{id}` | edit a VM | PUT | name,cpus,ram,nic,usb | `VM_EDIT_ANY` | OR | `VM_EDIT` | | +| `/vms/{id}/start` | start a VM | PUT | | `VM_START_ANY` | OR | `VM_START` | | +| `/vms/{id}/startwithcreds` | start a VM with credentials | PUT | port,pwd | `VM_START_ANY` | OR | `VM_START` | | +| `/vms/{id}/stop` | kill a VM | PUT | | `VM_STOP_ANY` | OR | `VM_STOP` | | +| `/vms/{id}/reboot` | reboot a VM | PUT | | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | | +| `/vms/{id}/shutdown` | gracefully shutdown a VM | PUT | | `VM_STOP_ANY` | OR | `VM_STOP` | | +| `/vms/{id}/access/{email}` | set VM access for a user | PUT | caps | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | | +| `/vms/{id}/access/{email}` | del VM access for a user | DELETE | | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | | +| `/vms/{id}/exportdir` | download a VM's dir | GET | dir | `VM_READFS_ANY` | OR | `VM_READFS` | | +| `/vms/{id}/importfiles` | upload files into a VM's dir | POST | files (.tar archive),dir | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | | ### Template management -| Route | Description | Method | Parameters | Req. user cap. | -|--- |--- |--- |--- |--- | -| `/templates` | returns templates that can be listed | GET | | `TPL_LIST_ANY` OR `TPL_LIST` | -| `/templates/{id}` | returns a template | GET | | `TPL_LIST_ANY` OR `TPL_LIST` | -| `/templates/vm` | create a template | POST | vmID,name,access | `TPL_CREATE` | -| `/templates/qcow` | create a template | POST | qcow,name,access | `TPL_CREATE` | -| `/templates/{id}` | edit a template | PUT | name,access | `TPL_EDIT_ANY` OR `TPL_EDIT` | -| `/templates/{id}` | delete a template | DELETE | | `TPL_DESTROY_ANY` OR `TPL_DESTROY` | -| `/templates/{id}/disk` | download a template's disk | GET | | `TPL_READFS_ANY` OR `TPL_READFS` | +| Route | Description | Method | Parameters | Req. user cap. | Output | +|--- |--- |--- |--- |--- |--- | +| `/templates` | returns templates that can be listed | GET | | `TPL_LIST_ANY` OR `TPL_LIST` | | +| `/templates/{id}` | returns a template | GET | | `TPL_LIST_ANY` OR `TPL_LIST` | | +| `/templates/vm` | create a template | POST | vmID,name,access | `TPL_CREATE` | | +| `/templates/qcow` | create a template | POST | qcow,name,access | `TPL_CREATE` | | +| `/templates/{id}` | edit a template | PUT | name,access | `TPL_EDIT_ANY` OR `TPL_EDIT` | | +| `/templates/{id}` | delete a template | DELETE | | `TPL_DESTROY_ANY` OR `TPL_DESTROY` | | +| `/templates/{id}/disk` | download a template's disk | GET | | `TPL_READFS_ANY` OR `TPL_READFS` | | Remarks: diff --git a/docs/server.md b/docs/server.md index dbb41a6fd131db7b3438d4e384a1b85749d85404..3877e6405e52286dd03aa1b31cf82499aa4be887 100644 --- a/docs/server.md +++ b/docs/server.md @@ -273,21 +273,27 @@ The host machine on which **nexus-server** will be running requires the followin - `guestfish` to export/import files from VM disk images (`guestfish` Ubuntu package) - `uuidgen` to generate UUIDs from bash (`uuid-runtime` Ubuntu package) -Note that Ubuntu 20.04 ships with QEMU 4.2.1 which is too old, but Ubuntu 22.04 ships with QEMU 6.2. +Note that Ubuntu 20.04 ships with QEMU 4.2.1 which is too old, but Ubuntu 22.04 ships with QEMU 6.2 and Ubuntu 24.04 ships with QEMU 8.2.2. + The latest stable version of QEMU can be downloaded from [https://www.qemu.org/download/](https://www.qemu.org/download/). To compile QEMU from source, read the [Compiling QEMU from source](##Compiling-QEMU-from-source) section at the end of this document. To get, build, and install **nexus-server**, these additionnal software are required: - git (`git` Ubuntu package) -- golang 1.18 or newer (`golang-go` Ubuntu 22.04 packages) +- golang 1.18 or newer (`golang-go` Ubuntu package) - GNU make (`make` Ubuntu package) - certtool (`gnutls-bin` Ubuntu package) -To install all the above-mentioned packages on a Ubuntu 22.04 system: +To install all the above-mentioned packages on a Ubuntu system: ``` sudo apt-get install -y qemu-system-x86 qemu-utils guestfish uuid-runtime git golang-go make gnutls-bin ``` +On Ubuntu 24.04, an additionnal package must be installed to support SPICE: +``` +sudo apt-get install -y qemu-system-modules-spice +``` + To make sure the KVM module is loaded into the kernel, run: ``` lsmod|grep kvm diff --git a/src/client/cmdUser/userResetPwd.go b/src/client/cmdUser/userResetPwd.go new file mode 100644 index 0000000000000000000000000000000000000000..661a1d034e4208c057343cec18d9038ee1208997 --- /dev/null +++ b/src/client/cmdUser/userResetPwd.go @@ -0,0 +1,70 @@ +package cmdUser + +import ( + "errors" + u "nexus-client/utils" + g "nexus-client/globals" +) + +type ResetPwd struct { + Name string +} + +func (cmd *ResetPwd)GetName() string { + return cmd.Name +} + +func (cmd *ResetPwd)GetDesc() []string { + return []string{ + "Reset password for one or more users.", + "Requires USER_RESETPWD user capability."} +} + +func (cmd *ResetPwd)PrintUsage() { + for _, desc := range cmd.GetDesc() { + u.PrintlnErr(desc) + } + u.PrintlnErr("―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――") + u.PrintlnErr("USAGE: "+cmd.GetName()+" email [email ...]") + u.PrintlnErr("―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――") +} + +func (cmd *ResetPwd)Run(args []string) int { + argc := len(args) + if argc < 1 { + cmd.PrintUsage() + return 1 + } + + statusCode := 0 + + // Iterates through each user (email) and reset their password + for i:= range(args) { + email := args[i] + resp, err := cmd.runRequest(email) + if err != nil { + u.PrintlnErr(err.Error()) + statusCode = 1 + } else { + u.Println("New password for user "+email+": "+resp) + } + } + + return statusCode +} + +func (cmd *ResetPwd)runRequest(email string) (string, error) { + client := g.GetInstance().Client + host := g.GetInstance().Host + + resp, err := client.R().Put(host+"/users/"+email+"/resetpwd") + if err != nil { + return "", errors.New("Error: "+err.Error()) + } + + if resp.IsSuccess() { + return resp.String(), nil + } else { + return "", errors.New("Error: "+resp.Status()+": "+resp.String()) + } +} diff --git a/src/client/cmdUser/userUnlock.go b/src/client/cmdUser/userUnlock.go new file mode 100644 index 0000000000000000000000000000000000000000..1d3845c24d55ab2aa2db29b75b0f1e03a1b139bd --- /dev/null +++ b/src/client/cmdUser/userUnlock.go @@ -0,0 +1,69 @@ +package cmdUser + +import ( + "errors" + u "nexus-client/utils" + g "nexus-client/globals" +) + +type Unlock struct { + Name string +} + +func (cmd *Unlock)GetName() string { + return cmd.Name +} + +func (cmd *Unlock)GetDesc() []string { + return []string{ + "Unlocks one or more users.", + "Requires USER_UNLOCK user capability."} +} + +func (cmd *Unlock)PrintUsage() { + for _, desc := range cmd.GetDesc() { + u.PrintlnErr(desc) + } + u.PrintlnErr("―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――") + u.PrintlnErr("USAGE: "+cmd.GetName()+" email [email ...]") + u.PrintlnErr("―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――") +} + +func (cmd *Unlock)Run(args []string) int { + argc := len(args) + if argc < 1 { + cmd.PrintUsage() + return 1 + } + + statusCode := 0 + + // Iterates through each user (email) and unlock them + for i:= range(args) { + email := args[i] + if err := cmd.runRequest(email); err != nil { + u.PrintlnErr(err.Error()) + statusCode = 1 + } else { + u.Println("Successfully unlocked user "+email) + } + } + + return statusCode +} + +func (cmd *Unlock)runRequest(email string) error { + client := g.GetInstance().Client + host := g.GetInstance().Host + + resp, err := client.R().Put(host+"/users/"+email+"/unlock") + if err != nil { + return errors.New("Error: "+err.Error()) + } + + if resp.IsSuccess() { + return nil + } else { + return errors.New("Error: "+resp.Status()+": "+resp.String()) + } +} diff --git a/src/client/cmdUser/userUpdatePwd.go b/src/client/cmdUser/userUpdatePwd.go index 7d045e33a7a673572251d0248e9ae85e1880781d..172cac8db6be53e2db859881ad2b868cbf2fe006 100644 --- a/src/client/cmdUser/userUpdatePwd.go +++ b/src/client/cmdUser/userUpdatePwd.go @@ -1,16 +1,13 @@ package cmdUser import ( + "bytes" + "golang.org/x/crypto/ssh/terminal" "nexus-common/params" u "nexus-client/utils" g "nexus-client/globals" ) -import ( - "bytes" - "golang.org/x/crypto/ssh/terminal" -) - type UpdatePwd struct { Name string } diff --git a/src/client/nexus-cli/nexus-cli.go b/src/client/nexus-cli/nexus-cli.go index 292b18a354960c48212efb3d1eb2fe3d2a465110..bc8a52aa7e3a06ab12ad20252ccc5994b1817833 100644 --- a/src/client/nexus-cli/nexus-cli.go +++ b/src/client/nexus-cli/nexus-cli.go @@ -37,7 +37,9 @@ var cmdList = []cmd.Command { &cmdUser.List{"userlist"}, &cmdUser.Add{"usercreate"}, &cmdUser.Del{"userdel"}, + &cmdUser.ResetPwd{"userresetpwd"}, &cmdUser.SetCaps{"usersetcaps"}, + &cmdUser.Unlock{"userunlock"}, &cmdMisc.HelpHeader{"!═════╡ TEMPLATE commands ╞═════════════════════════════════════════════════════════════════"}, &cmdTemplate.Create{"tplcreate"}, diff --git a/src/client/nexush/nexush.go b/src/client/nexush/nexush.go index 7a71505ebc4ec1b2306230cb1da324805eb9275b..ece4816d39430955607f9a0189d8681ff1d41b6e 100644 --- a/src/client/nexush/nexush.go +++ b/src/client/nexush/nexush.go @@ -42,7 +42,9 @@ var cmdList = []cmd.Command { &cmdUser.List{"userlist"}, &cmdUser.Add{"usercreate"}, &cmdUser.Del{"userdel"}, + &cmdUser.ResetPwd{"userresetpwd"}, &cmdUser.SetCaps{"usersetcaps"}, + &cmdUser.Unlock{"userunlock"}, &cmdMisc.HelpHeader{"!═════╡ TEMPLATE commands ╞═════════════════════════════════════════════════════════════════"}, &cmdTemplate.Create{"tplcreate"}, diff --git a/src/client/version/version.go b/src/client/version/version.go index 5aa7a035938a6c5e25bdf20943d95fb3697c8e09..4faa88c3d2ceb44019cfe6e18ebcf1e878e78e78 100644 --- a/src/client/version/version.go +++ b/src/client/version/version.go @@ -7,8 +7,8 @@ import ( const ( major = 1 - minor = 9 - bugfix = 4 + minor = 10 + bugfix = 0 ) type Version struct { diff --git a/src/common/caps/caps.go b/src/common/caps/caps.go index 94b66d061a6f7e598e02b2a25300cf16b71517ad..f4173606612292915c66975a726108e988761047 100644 --- a/src/common/caps/caps.go +++ b/src/common/caps/caps.go @@ -10,7 +10,8 @@ const ( CAP_USER_CREATE string = "USER_CREATE" CAP_USER_DESTROY = "USER_DESTROY" CAP_USER_SET_CAPS = "USER_SET_CAPS" - CAP_USER_SET_PWD = "USER_SET_PWD" + CAP_USER_UNLOCK = "USER_UNLOCK" + CAP_USER_RESETPWD = "USER_RESETPWD" CAP_USER_LIST = "USER_LIST" CAP_VM_LIST = "VM_LIST" @@ -51,6 +52,8 @@ var userCaps = Capabilities { CAP_USER_CREATE: 1, CAP_USER_DESTROY: 1, CAP_USER_SET_CAPS: 1, + CAP_USER_UNLOCK: 1, + CAP_USER_RESETPWD: 1, CAP_USER_LIST: 1, CAP_VM_CREATE: 1, diff --git a/src/common/params/login.go b/src/common/params/login.go index 1463ddcf3123aa445dcaff2c0754a692041e7144..9161bea0a94ca27988b12198ab9f6a757afe666a 100644 --- a/src/common/params/login.go +++ b/src/common/params/login.go @@ -5,3 +5,7 @@ type Login struct { Email string `json:"email" validate:"required,email"` Pwd string `json:"pwd" validate:"required,min=8"` } + +type Token struct { + Token string `json:"token" validate:"required"` +} \ No newline at end of file diff --git a/src/server/exec/QemuSystem.go b/src/server/exec/QemuSystem.go index 9f1e082a7e83ce1e27d93771d82ce0883e5d08e7..3084252229240b303792aa5d30f6a6bb04c73072 100644 --- a/src/server/exec/QemuSystem.go +++ b/src/server/exec/QemuSystem.go @@ -118,9 +118,9 @@ func NewQemuSystem(qgaSock string, cpus, ram int, nic string, usbDevs []string, args = append(args, "-device", "virtio-serial", "-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0", "-chardev", "socket,path="+qgaSock+",server=on,wait=off,id=qga0") // USB redirection args = append(args, usb...) - // Sound support - args = append(args, "-device", "intel-hda", "-device", "hda-duplex") - + // No sound support for now as we want to keep compatibility with recent and older qemu versions without introducing audio backends, etc. + // args = append(args, "-device", "intel-hda", "-device", "hda-duplex") + // To share a folder with the host: // - Add QEMU args: virtfs local,path=/tmp/pipo,mount_tag=sharedfs,security_model=none // - In the guest: mkdir shared && sudo mount -t 9p sharedfs shared diff --git a/src/server/router/auth.go b/src/server/router/auth.go index 72975fc3d5cf69297e2c2cb43f9fd11d040fafcf..465ac426170b206f00f575a04ebed226718449af 100644 --- a/src/server/router/auth.go +++ b/src/server/router/auth.go @@ -15,24 +15,24 @@ import ( ) // jwt.StandardClaims is added as an embedded type to provide fields like user email and expiration time. -type CustomClaims struct { +type customClaims struct { Email string jwt.StandardClaims } -type Auth struct { +type auth struct { users *users.Users tokenAccess middleware.JWTConfig } -var loginAttempts map[string]int // Keeps track of the number of wrong logins. -var loginLocked map[string]time.Time // Keeps track of when an account was locked. +var loginAttempts map[string]int // Number of consecutive wrong logins for the same user +var lockedUsers map[string]time.Time // Locked users and timestamps when their accounts were locked var jwtSecretKey string -// Creates an Auth object. -func NewAuth(u *users.Users) (*Auth, error) { +// Creates an auth object. +func NewAuth(u *users.Users) (*auth, error) { loginAttempts = make(map[string]int) - loginLocked = make(map[string]time.Time) + lockedUsers = make(map[string]time.Time) // TODO: better to store jwtSecretKey in an env. variable? // Generates a 64 characters long key with 20 digits, 0 symbols, @@ -43,12 +43,12 @@ func NewAuth(u *users.Users) (*Auth, error) { } jwtSecretKey = secret - return &Auth { + return &auth { users : u, tokenAccess : middleware.JWTConfig { ContextKey: "user", SigningKey: []byte(jwtSecretKey), - Claims: &CustomClaims{}, + Claims: &customClaims{}, ErrorHandlerWithContext: func(err error, c echo.Context) error { return echo.NewHTTPError(http.StatusUnauthorized, "access denied") }, @@ -57,14 +57,14 @@ func NewAuth(u *users.Users) (*Auth, error) { } // Token used by the JWT middleware to protect access to the specified group of routes. -func (auth *Auth)GetTokenAccess() middleware.JWTConfig { +func (auth *auth)GetTokenAccess() middleware.JWTConfig { return auth.tokenAccess } // curl --cacert ca.pem -X POST https://localhost:1077/login -H 'Content-Type: application/json' -d '{"email": "johndoe@nexus.org", "pwd":"the_user_password"}' // Returns a jwt token if authentication was successful: // {"token":"<jwt token>"} -func (auth *Auth)Login(c echo.Context) error { +func (auth *auth)Login(c echo.Context) error { // Deserializes the JSON body and checks its validity. reqUser := new(params.Login) if err := decodeJson(c, &reqUser); err != nil { @@ -77,59 +77,60 @@ func (auth *Auth)Login(c echo.Context) error { // User exists? user, err := auth.users.GetUserByEmail(reqUser.Email) if err != nil { + // return echo.NewHTTPError(http.StatusUnauthorized, "User not found") return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) } // Is user locked? - lockedTime, exists := loginLocked[user.Email] - if exists { - // If locked time has elapsed, unlock the account. + lockedTime, found := lockedUsers[user.Email] + if found { + // If locked time has elapsed, unlock the user. t := time.Now() elapsedTime := t.Sub(lockedTime) if elapsedTime.Hours() > conf.Auth.LockedTimeInHours { - delete(loginLocked, user.Email) - delete(loginAttempts, user.Email) + if err := UnlockUser(c, user.Email); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } } else { - log.Info("Account ", user.Email ," is still locked") - return echo.NewHTTPError(http.StatusUnauthorized, "Account is locked") + log.Info("User ", user.Email ," is locked") + return echo.NewHTTPError(http.StatusUnauthorized, "User is locked") } } // Checks the user password is correct. - // If a user enters the wrong password too many times, the account will be locked for a number of hours. - if err := bcrypt.CompareHashAndPassword([]byte(user.Pwd), []byte(reqUser.Pwd)); err != nil { + // If a user enters the wrong password too many times, the user will be locked for a number of hours. + err = bcrypt.CompareHashAndPassword([]byte(user.Pwd), []byte(reqUser.Pwd)) + // If err == nil, passwords match! + if (err == nil) { + loginAttempts[user.Email] = 0 + } else { // Stores the number of attempted logins. - count, exists := loginAttempts[user.Email] - if exists { - loginAttempts[user.Email] = count+1 - if count >= conf.Auth.MaxLoginAttempts { - // Locks the account. - loginLocked[user.Email] = time.Now() - log.Info("Account ", user.Email ," is locked: too many wrong password attempts!") - return echo.NewHTTPError(http.StatusUnauthorized, "Account is locked") - } + count, found := loginAttempts[user.Email] + if found { + count = count+1 } else { - loginAttempts[user.Email] = 1 + count = 1 + } + loginAttempts[user.Email] = count + log.Info("Login attempt ", count, " for user ", user.Email) + if count > conf.Auth.MaxLoginAttempts { + // Locks user + lockedUsers[user.Email] = time.Now() + log.Info("User ", user.Email ," is locked: too many wrong password attempts!") + return echo.NewHTTPError(http.StatusUnauthorized, "User is locked") } - // If the two passwords don't match, return a 401 status. + // If passwords don't match, return a 401 status. return echo.NewHTTPError(http.StatusUnauthorized, "Invalid password") - } else { - loginAttempts[user.Email] = 0 - } - - // Retrieves user from its email. - user, err = auth.users.GetUserByEmail(reqUser.Email) - if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "User not found") } - token, err := createToken(user.Email) + tok, err := createToken(user.Email) if err != nil { return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) } - return c.JSON(http.StatusOK, echo.Map{"token": token}) + tokenSerialized := params.Token{ Token: tok } + return c.JSON(http.StatusOK, tokenSerialized) } // Creates a JWT token and claims with the expiration time for the given user. @@ -137,7 +138,7 @@ func createToken(userEmail string) (string, error) { expirationTime := time.Now().Add(time.Duration(conf.Auth.AccessTokenExpirationTimeInHours)*time.Hour) // Creates the claims - claims := &CustomClaims{ + claims := &customClaims{ Email: userEmail, StandardClaims: jwt.StandardClaims { // JWT expresses expiration time in Unix milliseconds @@ -166,11 +167,30 @@ func GetUserFromContext(c echo.Context) (string, error) { } user := c.Get("user").(*jwt.Token) - claims := user.Claims.(*CustomClaims) + claims := user.Claims.(*customClaims) return claims.Email, nil } +func UnlockUser(c echo.Context, emailToUnlock string) error { + userEmail, err := GetUserFromContext(c) + if err != nil { + return err + } + + _, found := lockedUsers[emailToUnlock] + if found { + // unlock user by removing it from the locked map + delete(lockedUsers, emailToUnlock) + loginAttempts[emailToUnlock] = 0 // reset the number of attempts + log.Info("User ", userEmail, " unlocked user ", emailToUnlock) + return nil + } else { + return errors.New("User not locked") + } + +} + /* // Example of middleware // Such a middleware could be used to handle a logout feature. @@ -187,7 +207,7 @@ func OnlyJoe(next echo.HandlerFunc) echo.HandlerFunc { return echo.NewHTTPError(http.StatusUnauthorized, "invalid user token\n") } user := c.Get("user").(*jwt.Token) - claims := user.Claims.(*CustomClaims) + claims := user.Claims.(*customClaims) username := claims.Username if username != "joe" { return echo.ErrUnauthorized diff --git a/src/server/router/router.go b/src/server/router/router.go index 24b5516730c41fd5c760318907032861d94c8be4..6c62576c98c5522f51bb00590b5c079f1f39f84c 100644 --- a/src/server/router/router.go +++ b/src/server/router/router.go @@ -66,6 +66,8 @@ func (router *Router)Start(port int) { usersGroup.POST("", router.users.CreateUser) usersGroup.DELETE("/:email", router.users.DeleteUserByEmail) usersGroup.PUT("/:email/caps", router.users.SetUserCaps) + usersGroup.PUT("/:email/unlock", router.users.UnlockUser) + usersGroup.PUT("/:email/resetpwd", router.users.ResetPwd) usersGroup.PUT("/pwd", router.users.SetUserPwd) // VM management. diff --git a/src/server/router/routerUsers.go b/src/server/router/routerUsers.go index 05b6144516c74c69a6e7c02ec68c3a0d9826cd96..0f367b5a0cd7bacad38ef7ab69f540837149f40c 100644 --- a/src/server/router/routerUsers.go +++ b/src/server/router/routerUsers.go @@ -21,7 +21,7 @@ func NewRouterUsers() *RouterUsers { // Lists users. // Requires CAP_USER_LIST. // curl --cacert ca.pem -X GET http://localhost:1077/users -H "Authorization: Bearer <AccessToken>" -func (r *RouterUsers) GetUsers(c echo.Context) error { +func (r *RouterUsers)GetUsers(c echo.Context) error { // Retrieves logged user from context. user, err := getLoggedUser(r.users, c) if err != nil { @@ -43,7 +43,7 @@ func (r *RouterUsers) GetUsers(c echo.Context) error { // Retrieves the logged (authententicated) user. // curl --cacert ca.pem -X GET http://localhost:1077/users/me -H "Authorization: Bearer <AccessToken>" -func (r *RouterUsers) GetLoggedUser(c echo.Context) error { +func (r *RouterUsers)GetLoggedUser(c echo.Context) error { user, err := getLoggedUser(r.users, c) if err != nil { return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) @@ -55,7 +55,7 @@ func (r *RouterUsers) GetLoggedUser(c echo.Context) error { // Deletes a user by its ID. // Requires CAP_USER_DESTROY. // curl --cacert ca.pem -X DELETE http://localhost:1077/users/janedoes@nexus.org -H "Authorization: Bearer <AccessToken>" -func (r *RouterUsers) DeleteUserByEmail(c echo.Context) error { +func (r *RouterUsers)DeleteUserByEmail(c echo.Context) error { // Retrieves logged user from context. user, err := getLoggedUser(r.users, c) if err != nil { @@ -81,7 +81,7 @@ func (r *RouterUsers) DeleteUserByEmail(c echo.Context) error { // Creates a user. // Requires CAP_USER_CREATE. // curl --cacert ca.pem -X POST http://localhost:1077/users -H 'Content-Type: application/json' -d '{"email":"johndoe@nexus.org","firstName":"John","lastName":"Doe","pwd":"pipomolo","caps":{"USER_LIST":1,"VM_CREATE":1, "VM_SET_ACCESS":1}}' -H "Authorization: Bearer <AccessToken>" -func (r *RouterUsers) CreateUser(c echo.Context) error { +func (r *RouterUsers)CreateUser(c echo.Context) error { // Retrieves logged user from context. user, err := getLoggedUser(r.users, c) if err != nil { @@ -118,7 +118,7 @@ func (r *RouterUsers) CreateUser(c echo.Context) error { // Update a user's capabilities. // Requires CAP_USER_SET_CAPS. // curl --cacert ca.pem -X PUT http://localhost:1077/users/janedoes@nexus.org/caps -H 'Content-Type: application/json' -d '{"caps":{"USER_LIST":1,"VM_CREATE":1, "VM_SET_ACCESS":1}}' -H "Authorization: Bearer <AccessToken>" -func (r *RouterUsers) SetUserCaps(c echo.Context) error { +func (r *RouterUsers)SetUserCaps(c echo.Context) error { // Retrieves logged user from context. user, err := getLoggedUser(r.users, c) if err != nil { @@ -159,7 +159,7 @@ func (r *RouterUsers) SetUserCaps(c echo.Context) error { // Update the logged user's password. // curl --cacert ca.pem -X PUT http://localhost:1077/users/pwd -H 'Content-Type: application/json' -d '{"pwd": "new_password"}' -H "Authorization: Bearer <AccessToken>" -func (r *RouterUsers) SetUserPwd(c echo.Context) error { +func (r *RouterUsers)SetUserPwd(c echo.Context) error { // Deserializes and validates client's parameters. p := new(params.UserSetPwd) if err := decodeJson(c, &p); err != nil { @@ -187,3 +187,71 @@ func (r *RouterUsers) SetUserPwd(c echo.Context) error { return c.JSONPretty(http.StatusOK, jsonMsg("OK"), " ") } + +// Unlock a user. +// Requires CAP_USER_UNLOCK. +// curl --cacert ca.pem -X PUT http://localhost:1077/users/janedoes@nexus.org/unlock -H 'Content-Type: application/json' -H "Authorization: Bearer <AccessToken>" +func (r *RouterUsers)UnlockUser(c echo.Context) error { + // Retrieves logged user from context. + user, err := getLoggedUser(r.users, c) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) + } + + if !user.HasCapability(caps.CAP_USER_UNLOCK) { + return echo.NewHTTPError(http.StatusUnauthorized, msgInsufficientCaps) + } + + userToUnlock, err := r.users.GetUserByEmail(c.Param("email")) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + if err = UnlockUser(c, userToUnlock.Email); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + return c.JSONPretty(http.StatusOK, jsonMsg("OK"), " ") +} + +// Reset a user's password. +// Requires CAP_USER_RESETPWD. +// curl --cacert ca.pem -X PUT http://localhost:1077/users/janedoes@nexus.org/resetpwd -H 'Content-Type: application/json' -H "Authorization: Bearer <AccessToken>" +func (r *RouterUsers)ResetPwd(c echo.Context) error { + // Retrieves logged user from context. + user, err := getLoggedUser(r.users, c) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) + } + + if !user.HasCapability(caps.CAP_USER_RESETPWD) { + return echo.NewHTTPError(http.StatusUnauthorized, msgInsufficientCaps) + } + + userToResetPwd, err := r.users.GetUserByEmail(c.Param("email")) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + pwdLength := 8 + pwdDigitCount := 4 + pwdSymbolCount := 0 + pwdRepeatChars := false + randomPwd, err := utils.CustomPwd.Generate(pwdLength, pwdDigitCount, pwdSymbolCount, false, pwdRepeatChars) + if err != nil { + msg := "Failed to reset password for user "+userToResetPwd.Email+": "+err.Error() + log.Error(msg) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + userToResetPwd.Pwd = utils.HashPassword(randomPwd) + + // Updates user and saves the new user file. + if err = r.users.UpdateUser(&userToResetPwd); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + log.Info("User ", user.Email, " reset password for user ", userToResetPwd.Email) + + return c.JSON(http.StatusOK, ¶ms.UserSetPwd{randomPwd}) +} diff --git a/src/server/utils/password.go b/src/server/utils/password.go new file mode 100644 index 0000000000000000000000000000000000000000..bffa2fda5ec1476e0528cc56c9f537250bed0a11 --- /dev/null +++ b/src/server/utils/password.go @@ -0,0 +1,13 @@ +package utils + +import ( + "github.com/sethvargo/go-password/password" +) + +// Custom password generator with the following characters removed: 0, O, l, I +var CustomPwd, _ = password.NewGenerator(&password.GeneratorInput{ + LowerLetters: "abcdefghijkmnopqrstuvwxyz", + UpperLetters: "ABCDEFGHJKLMNPQRSTUVWXYZ", + Digits: "123456789", +}) + diff --git a/src/server/version/version.go b/src/server/version/version.go index 96c9c92bfc1670831212e2d061e57403528e2bdb..e842cbab98a6c78c06ec3e4d0cc592c71c42f242 100644 --- a/src/server/version/version.go +++ b/src/server/version/version.go @@ -6,8 +6,8 @@ import ( const ( major = 1 - minor = 9 - bugfix = 3 + minor = 10 + bugfix = 0 ) type Version struct { diff --git a/src/server/vms/vm.go b/src/server/vms/vm.go index 1aca6263aff0f3450507b91d9f3534f9185dce6c..681e35db4e4dca7ea2273168d7abec51c1a1b2ac 100644 --- a/src/server/vms/vm.go +++ b/src/server/vms/vm.go @@ -15,7 +15,6 @@ import ( "nexus-server/exec" "github.com/google/uuid" "github.com/go-playground/validator/v10" - "github.com/sethvargo/go-password/password" ) type ( @@ -49,13 +48,6 @@ const ( vmQGASockFile = "qga.sock" ) -// Custom password generator with the following characters removed: 0, O, l, I -var passwordGen, _ = password.NewGenerator(&password.GeneratorInput{ - LowerLetters: "abcdefghijkmnopqrstuvwxyz", - UpperLetters: "ABCDEFGHJKLMNPQRSTUVWXYZ", - Digits: "123456789", -}) - // Creates a VM and returns a pointer to it. // Concurrency: safe func NewVM(creatorEmail string, name string, cpus, ram int, nic vmc.NicType, usbDevs []string, templateID uuid.UUID, owner string) (*VM, error) { diff --git a/src/server/vms/vms.go b/src/server/vms/vms.go index ba927da2b0b6b42be7a9a8bf4b2152221930f587..00b7c99caa4acc96cdffb3f289feaa2d717c7539 100644 --- a/src/server/vms/vms.go +++ b/src/server/vms/vms.go @@ -403,7 +403,7 @@ func (vms *VMs)StartVM(vmID uuid.UUID) (int, string, error) { // Randomly generates a 8 characters long password with 4 digits, 0 symbols, // allowing upper and lower case letters, disallowing repeat characters. - pwd, err := passwordGen.Generate(conf.VMPwdLength, conf.VMPwdDigitCount, conf.VMPwdSymbolCount, false, conf.VMPwdRepeatChars) + pwd, err := utils.CustomPwd.Generate(conf.VMPwdLength, conf.VMPwdDigitCount, conf.VMPwdSymbolCount, false, conf.VMPwdRepeatChars) if err != nil { msg := "Failed starting VM "+vmID.String()+": password generation error: "+err.Error() log.Error(msg)