diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index 5728ed3..7531f3e 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -221,6 +221,16 @@ "button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", "button-register-title": "Passkey registrieren", "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern." + }, + "password": { + "headline": "Passwort-Einstellungen", + "abstract": "Hier können Sie Ihr Passwort ändern.", + "current-label": "Aktuelles Passwort", + "new-label": "Neues Passwort", + "new-confirm-label": "Neues Passwort bestätigen", + "change-button-text": "Passwort ändern", + "invalid-confirm-label": "Passwörter stimmen nicht überein", + "weak-label": "Passwort ist zu schwach" } }, "audit": { diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json index 9edeee6..5193389 100644 --- a/frontend/src/lang/translations/en.json +++ b/frontend/src/lang/translations/en.json @@ -221,6 +221,16 @@ "button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.", "button-register-title": "Register Passkey", "button-register-text": "Register a new Passkey to secure your account." + }, + "password": { + "headline": "Password Settings", + "abstract": "Here you can change your password.", + "current-label": "Current Password", + "new-label": "New Password", + "new-confirm-label": "Confirm New Password", + "change-button-text": "Change Password", + "invalid-confirm-label": "Passwords do not match", + "weak-label": "Password is too weak" } }, "audit": { diff --git a/frontend/src/stores/profile.js b/frontend/src/stores/profile.js index ba3798c..cbb9864 100644 --- a/frontend/src/stores/profile.js +++ b/frontend/src/stores/profile.js @@ -151,6 +151,17 @@ export const profileStore = defineStore('profile', { }) }) }, + async changePassword(formData) { + this.fetching = true + let currentUser = authStore().user.Identifier + return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/change-password`, formData) + .then(this.fetching = false) + .catch(error => { + this.fetching = false; + console.log("Failed to change password for ", currentUser, ": ", error); + throw new Error(error); + }); + }, async LoadPeers() { this.fetching = true let currentUser = authStore().user.Identifier diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index e5c6fb3..2e8c2f6 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1,8 +1,9 @@ diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index 32821a8..cc04fce 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -1550,6 +1550,38 @@ } } }, + "/user/{id}/change-password": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Change the password for the given user.", + "operationId": "users_handleChangePasswordPost", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Error" + } + } + } + } + }, "/user/{id}/interfaces": { "get": { "produces": [ @@ -2159,6 +2191,10 @@ } ] }, + "UserDisplayName": { + "description": "the owner display name", + "type": "string" + }, "UserIdentifier": { "description": "the owner", "type": "string" diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index 6a28b99..c1599a9 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -322,6 +322,9 @@ definitions: allOf: - $ref: '#/definitions/model.ConfigOption-string' description: the routing table + UserDisplayName: + description: the owner display name + type: string UserIdentifier: description: the owner type: string @@ -1442,6 +1445,27 @@ paths: summary: Enable the REST API for the given user. tags: - Users + /user/{id}/change-password: + post: + operationId: users_handleChangePasswordPost + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.User' + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Error' + summary: Change the password for the given user. + tags: + - Users /user/{id}/interfaces: get: operationId: users_handleInterfacesGet diff --git a/internal/app/api/core/assets/doc/v1_swagger.json b/internal/app/api/core/assets/doc/v1_swagger.json index 9d363dd..ce4b180 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.json +++ b/internal/app/api/core/assets/doc/v1_swagger.json @@ -17,11 +17,6 @@ "paths": { "/interface/all": { "get": { - "security": [ - { - "BasicAuth": [] - } - ], "produces": [ "application/json" ], @@ -52,16 +47,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/interface/by-id/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/interface/by-id/{id}": { + "get": { "produces": [ "application/json" ], @@ -110,14 +105,14 @@ "$ref": "#/definitions/models.Error" } } - } - }, - "put": { + }, "security": [ { "BasicAuth": [] } - ], + ] + }, + "put": { "description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", "produces": [ "application/json" @@ -182,14 +177,14 @@ "$ref": "#/definitions/models.Error" } } - } - }, - "delete": { + }, "security": [ { "BasicAuth": [] } - ], + ] + }, + "delete": { "produces": [ "application/json" ], @@ -241,16 +236,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/interface/new": { - "post": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/interface/new": { + "post": { "description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", "produces": [ "application/json" @@ -308,16 +303,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/interface/prepare": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/interface/prepare": { + "get": { "description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).", "produces": [ "application/json" @@ -352,16 +347,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/metrics/by-interface/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/metrics/by-interface/{id}": { + "get": { "produces": [ "application/json" ], @@ -410,16 +405,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/metrics/by-peer/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/metrics/by-peer/{id}": { + "get": { "produces": [ "application/json" ], @@ -468,16 +463,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/metrics/by-user/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/metrics/by-user/{id}": { + "get": { "produces": [ "application/json" ], @@ -526,16 +521,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/peer/by-id/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/peer/by-id/{id}": { + "get": { "description": "Normal users can only access their own records. Admins can access all records.", "produces": [ "application/json" @@ -585,14 +580,14 @@ "$ref": "#/definitions/models.Error" } } - } - }, - "put": { + }, "security": [ { "BasicAuth": [] } - ], + ] + }, + "put": { "description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).", "produces": [ "application/json" @@ -657,14 +652,14 @@ "$ref": "#/definitions/models.Error" } } - } - }, - "delete": { + }, "security": [ { "BasicAuth": [] } - ], + ] + }, + "delete": { "produces": [ "application/json" ], @@ -716,16 +711,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/peer/by-interface/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/peer/by-interface/{id}": { + "get": { "produces": [ "application/json" ], @@ -765,16 +760,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/peer/by-user/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/peer/by-user/{id}": { + "get": { "description": "Normal users can only access their own records. Admins can access all records.", "produces": [ "application/json" @@ -815,16 +810,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/peer/new": { - "post": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/peer/new": { + "post": { "description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).", "produces": [ "application/json" @@ -882,16 +877,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/peer/prepare/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/peer/prepare/{id}": { + "get": { "description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.", "produces": [ "application/json" @@ -947,16 +942,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/provisioning/data/peer-config": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/provisioning/data/peer-config": { + "get": { "description": "Normal users can only access their own record. Admins can access all records.", "produces": [ "text/plain", @@ -1013,16 +1008,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/provisioning/data/peer-qr": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/provisioning/data/peer-qr": { + "get": { "description": "Normal users can only access their own record. Admins can access all records.", "produces": [ "image/png", @@ -1079,16 +1074,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/provisioning/data/user-info": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/provisioning/data/user-info": { + "get": { "description": "Normal users can only access their own record. Admins can access all records.", "produces": [ "application/json" @@ -1149,16 +1144,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/provisioning/new-peer": { - "post": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/provisioning/new-peer": { + "post": { "description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.", "produces": [ "application/json" @@ -1216,16 +1211,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/user/all": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/user/all": { + "get": { "produces": [ "application/json" ], @@ -1256,16 +1251,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/user/by-id/{id}": { - "get": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/user/by-id/{id}": { + "get": { "description": "Normal users can only access their own record. Admins can access all records.", "produces": [ "application/json" @@ -1315,14 +1310,14 @@ "$ref": "#/definitions/models.Error" } } - } - }, - "put": { + }, "security": [ { "BasicAuth": [] } - ], + ] + }, + "put": { "description": "Only admins can update existing records.", "produces": [ "application/json" @@ -1387,14 +1382,14 @@ "$ref": "#/definitions/models.Error" } } - } - }, - "delete": { + }, "security": [ { "BasicAuth": [] } - ], + ] + }, + "delete": { "produces": [ "application/json" ], @@ -1446,16 +1441,16 @@ "$ref": "#/definitions/models.Error" } } - } - } - }, - "/user/new": { - "post": { + }, "security": [ { "BasicAuth": [] } - ], + ] + } + }, + "/user/new": { + "post": { "description": "Only admins can create new records.", "produces": [ "application/json" @@ -1513,7 +1508,12 @@ "$ref": "#/definitions/models.Error" } } - } + }, + "security": [ + { + "BasicAuth": [] + } + ] } } }, diff --git a/internal/app/api/v0/backend/user_service.go b/internal/app/api/v0/backend/user_service.go index 74fee34..7a0279d 100644 --- a/internal/app/api/v0/backend/user_service.go +++ b/internal/app/api/v0/backend/user_service.go @@ -2,6 +2,8 @@ package backend import ( "context" + "fmt" + "strings" "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" @@ -70,6 +72,44 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier return u.users.DeactivateApi(ctx, id) } +func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) { + oldPassword = strings.TrimSpace(oldPassword) + newPassword = strings.TrimSpace(newPassword) + + if newPassword == "" { + return nil, fmt.Errorf("new password must not be empty") + } + + // ensure that the new password is different from the old one + if oldPassword == newPassword { + return nil, fmt.Errorf("new password must be different from the old one") + } + + user, err := u.users.GetUser(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + // ensure that the user uses the database backend; otherwise we can't change the password + if user.Source != domain.UserSourceDatabase { + return nil, fmt.Errorf("user source %s does not support password changes", user.Source) + } + + // validate old password + if user.CheckPassword(oldPassword) != nil { + return nil, fmt.Errorf("current password is invalid") + } + + user.Password = domain.PrivateString(newPassword) + + // ensure that the new password is strong enough + if err := user.HasWeakPassword(u.cfg.Auth.MinPasswordLength); err != nil { + return nil, err + } + + return u.users.UpdateUser(ctx, user) +} + func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) { return u.wg.GetUserPeers(ctx, id) } diff --git a/internal/app/api/v0/handlers/endpoint_users.go b/internal/app/api/v0/handlers/endpoint_users.go index 4cad7b2..cc3323b 100644 --- a/internal/app/api/v0/handlers/endpoint_users.go +++ b/internal/app/api/v0/handlers/endpoint_users.go @@ -28,6 +28,8 @@ type UserService interface { ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) // DeactivateApi disables the API for the user with the given id. DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) + // ChangePassword changes the password for the user with the given id. + ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) // GetUserPeers returns all peers for the given user. GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) // GetUserPeerStats returns all peer stats for the given user. @@ -75,6 +77,7 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) { apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost()) + apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password", e.handleChangePasswordPost()) } // handleAllGet returns a gorm Handler function. @@ -391,3 +394,68 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc { respond.JSON(w, http.StatusOK, model.NewUser(user, false)) } } + +// handleChangePasswordPost returns a gorm Handler function. +// +// @ID users_handleChangePasswordPost +// @Tags Users +// @Summary Change the password for the given user. +// @Produce json +// @Success 200 {object} model.User +// @Failure 400 {object} model.Error +// @Failure 500 {object} model.Error +// @Router /user/{id}/change-password [post] +func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userId := Base64UrlDecode(request.Path(r, "id")) + if userId == "" { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) + return + } + + var passwordData struct { + OldPassword string `json:"OldPassword"` + Password string `json:"Password"` + PasswordRepeat string `json:"PasswordRepeat"` + } + if err := request.BodyJson(r, &passwordData); err != nil { + respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + if passwordData.OldPassword == "" { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "old password missing"}) + return + } + + if passwordData.Password == "" { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "new password missing"}) + return + } + + if passwordData.OldPassword == passwordData.Password { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "password did not change"}) + return + } + + if passwordData.Password != passwordData.PasswordRepeat { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "password mismatch"}) + return + } + + user, err := e.userService.ChangePassword(r.Context(), domain.UserIdentifier(userId), + passwordData.OldPassword, passwordData.Password) + if err != nil { + respond.JSON(w, http.StatusInternalServerError, + model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewUser(user, false)) + } +}