From 0bcb80537aa3a87cc57e47baa3b98c122321eee7 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 9 Oct 2022 11:50:50 -0500 Subject: [PATCH] API implementation for changing password --- Taskfile.yml | 2 +- backend/app/api/docs/docs.go | 40 ++++++++++++++++++++ backend/app/api/docs/swagger.json | 40 ++++++++++++++++++++ backend/app/api/docs/swagger.yaml | 24 ++++++++++++ backend/app/api/routes.go | 1 + backend/app/api/v1/v1_ctrl_user.go | 34 +++++++++++++++++ backend/internal/repo/repo_users.go | 4 ++ backend/internal/services/service_user.go | 26 +++++++++++++ frontend/lib/api/__test__/factories/index.ts | 8 +++- frontend/lib/api/__test__/user/user.test.ts | 27 +++++++++++++ frontend/lib/api/classes/users.ts | 12 +++++- frontend/lib/api/types/data-contracts.ts | 5 +++ 12 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 frontend/lib/api/__test__/user/user.test.ts diff --git a/Taskfile.yml b/Taskfile.yml index 0b8049e..a4b1972 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -20,7 +20,7 @@ tasks: --path ./backend/app/api/docs/swagger.json \ --output ./frontend/lib/api/types - python3 ./scripts/process-types.py ./frontend/lib/api/types/data-contracts.ts + - python3 ./scripts/process-types.py ./frontend/lib/api/types/data-contracts.ts sources: - "./backend/app/api/**/*" - "./backend/internal/repo/**/*" diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index 784c591..32af2e3 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -822,6 +822,35 @@ const docTemplate = `{ } } }, + "/v1/users/change-password": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "tags": [ + "User" + ], + "summary": "Updates the users password", + "parameters": [ + { + "description": "Password Payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.ChangePassword" + } + } + ], + "responses": { + "204": { + "description": "" + } + } + } + }, "/v1/users/login": { "post": { "consumes": [ @@ -1579,6 +1608,17 @@ const docTemplate = `{ } } }, + "v1.ChangePassword": { + "type": "object", + "properties": { + "current": { + "type": "string" + }, + "new": { + "type": "string" + } + } + }, "v1.GroupInvitation": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index 2e6dea6..831066f 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -814,6 +814,35 @@ } } }, + "/v1/users/change-password": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "tags": [ + "User" + ], + "summary": "Updates the users password", + "parameters": [ + { + "description": "Password Payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.ChangePassword" + } + } + ], + "responses": { + "204": { + "description": "" + } + } + } + }, "/v1/users/login": { "post": { "consumes": [ @@ -1571,6 +1600,17 @@ } } }, + "v1.ChangePassword": { + "type": "object", + "properties": { + "current": { + "type": "string" + }, + "new": { + "type": "string" + } + } + }, "v1.GroupInvitation": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index ae460a6..e52d258 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -353,6 +353,13 @@ definitions: version: type: string type: object + v1.ChangePassword: + properties: + current: + type: string + new: + type: string + type: object v1.GroupInvitation: properties: expiresAt: @@ -877,6 +884,23 @@ paths: summary: Retrieves the basic information about the API tags: - Base + /v1/users/change-password: + put: + parameters: + - description: Password Payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/v1.ChangePassword' + responses: + "204": + description: "" + security: + - Bearer: [] + summary: Updates the users password + tags: + - User /v1/users/login: post: consumes: diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index b9e93be..3b131b2 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -65,6 +65,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { r.Put(v1Base("/users/self/password"), v1Ctrl.HandleUserUpdatePassword()) r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout()) r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh()) + r.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword()) r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate()) diff --git a/backend/app/api/v1/v1_ctrl_user.go b/backend/app/api/v1/v1_ctrl_user.go index eba2219..4c6b71c 100644 --- a/backend/app/api/v1/v1_ctrl_user.go +++ b/backend/app/api/v1/v1_ctrl_user.go @@ -119,3 +119,37 @@ func (ctrl *V1Controller) HandleUserSelfDelete() http.HandlerFunc { server.Respond(w, http.StatusNoContent, nil) } } + +type ( + ChangePassword struct { + Current string `json:"current,omitempty"` + New string `json:"new,omitempty"` + } +) + +// HandleUserSelfChangePassword godoc +// @Summary Updates the users password +// @Tags User +// @Success 204 +// @Param payload body ChangePassword true "Password Payload" +// @Router /v1/users/change-password [PUT] +// @Security Bearer +func (ctrl *V1Controller) HandleUserSelfChangePassword() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var cp ChangePassword + err := server.Decode(r, &cp) + if err != nil { + log.Err(err).Msg("user failed to change password") + } + + ctx := services.NewContext(r.Context()) + + ok := ctrl.svc.User.ChangePassword(ctx, cp.Current, cp.New) + if !ok { + server.RespondError(w, http.StatusInternalServerError, err) + return + } + + server.Respond(w, http.StatusNoContent, nil) + } +} diff --git a/backend/internal/repo/repo_users.go b/backend/internal/repo/repo_users.go index f05dbb6..9763e38 100644 --- a/backend/internal/repo/repo_users.go +++ b/backend/internal/repo/repo_users.go @@ -121,3 +121,7 @@ func (e *UserRepository) GetSuperusers(ctx context.Context) ([]*ent.User, error) return users, nil } + +func (r *UserRepository) ChangePassword(ctx context.Context, UID uuid.UUID, pw string) error { + return r.db.User.UpdateOneID(UID).SetPassword(pw).Exec(ctx) +} diff --git a/backend/internal/services/service_user.go b/backend/internal/services/service_user.go index 6e192de..e8bc00c 100644 --- a/backend/internal/services/service_user.go +++ b/backend/internal/services/service_user.go @@ -196,3 +196,29 @@ func (svc *UserService) NewInvitation(ctx Context, uses int, expiresAt time.Time return token.Raw, nil } + +func (svc *UserService) ChangePassword(ctx Context, current string, new string) (ok bool) { + usr, err := svc.repos.Users.GetOneId(ctx, ctx.UID) + if err != nil { + return false + } + + if !hasher.CheckPasswordHash(current, usr.PasswordHash) { + log.Err(errors.New("current password is incorrect")).Msg("Failed to change password") + return false + } + + hashed, err := hasher.HashPassword(new) + if err != nil { + log.Err(err).Msg("Failed to hash password") + return false + } + + err = svc.repos.Users.ChangePassword(ctx.Context, ctx.UID, hashed) + if err != nil { + log.Err(err).Msg("Failed to change password") + return false + } + + return true +} diff --git a/frontend/lib/api/__test__/factories/index.ts b/frontend/lib/api/__test__/factories/index.ts index ce905fe..51a0fb0 100644 --- a/frontend/lib/api/__test__/factories/index.ts +++ b/frontend/lib/api/__test__/factories/index.ts @@ -1,4 +1,5 @@ import { faker } from "@faker-js/faker"; +import { expect } from "vitest"; import { overrideParts } from "../../base/urls"; import { PublicApi } from "../../public"; import { LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts"; @@ -55,8 +56,11 @@ async function userSingleUse(): Promise { const usr = user(); const pub = publicClient(); - pub.register(usr); - const result = await pub.login(usr.name, usr.password); + await pub.register(usr); + const result = await pub.login(usr.email, usr.password); + + expect(result.error).toBeFalsy(); + expect(result.status).toBe(200); return { client: new UserClient(new Requests("", result.data.token)), diff --git a/frontend/lib/api/__test__/user/user.test.ts b/frontend/lib/api/__test__/user/user.test.ts new file mode 100644 index 0000000..ca4174d --- /dev/null +++ b/frontend/lib/api/__test__/user/user.test.ts @@ -0,0 +1,27 @@ +import { faker } from "@faker-js/faker"; +import { describe, expect, test } from "vitest"; +import { factories } from "../factories"; + +describe("basic user workflows", () => { + test("user should be able to change password", async () => { + const { client, user } = await factories.client.singleUse(); + const password = faker.internet.password(); + + // Change Password + { + const response = await client.user.changePassword(user.password, password); + expect(response.error).toBeFalsy(); + expect(response.status).toBe(204); + } + + // Ensure New Login is Valid + { + const pub = factories.client.public(); + const response = await pub.login(user.email, password); + expect(response.error).toBeFalsy(); + expect(response.status).toBe(200); + } + + await client.user.delete(); + }, 20000); +}); diff --git a/frontend/lib/api/classes/users.ts b/frontend/lib/api/classes/users.ts index 39f03b4..21006d0 100644 --- a/frontend/lib/api/classes/users.ts +++ b/frontend/lib/api/classes/users.ts @@ -1,5 +1,5 @@ import { BaseAPI, route } from "../base"; -import { UserOut } from "../types/data-contracts"; +import { ChangePassword, UserOut } from "../types/data-contracts"; import { Result } from "../types/non-generated"; export class UserApi extends BaseAPI { @@ -14,4 +14,14 @@ export class UserApi extends BaseAPI { public delete() { return this.http.delete({ url: route("/users/self") }); } + + public changePassword(current: string, newPassword: string) { + return this.http.put({ + url: route("/users/self/change-password"), + body: { + current, + new: newPassword, + }, + }); + } } diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index bd15e83..3111a85 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -238,6 +238,11 @@ export interface Build { version: string; } +export interface ChangePassword { + current: string; + new: string; +} + export interface GroupInvitation { expiresAt: Date; token: string;