diff --git a/go/api/openapi.yaml b/go/api/openapi.yaml index d08e75f79..6ab5e805e 100644 --- a/go/api/openapi.yaml +++ b/go/api/openapi.yaml @@ -295,6 +295,30 @@ paths: description: >- Email verification email sent successfully + /user/password: + post: + summary: >- + Change user password. The user must be authenticated or provide a ticket + tags: + - user + - password + security: + - BearerAuthElevated: [] + - {} + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserPasswordRequest' + responses: + '200': + description: >- + Password changed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/OKResponse' + /user/password/reset: post: summary: >- @@ -432,6 +456,7 @@ components: - user-not-anonymous - invalid-pat - invalid-refresh-token + - invalid-ticket required: - status - message @@ -641,6 +666,23 @@ components: required: - email + UserPasswordRequest: + type: object + additionalProperties: false + properties: + newPassword: + description: A password of minimum 3 characters + example: Str0ngPassw#ord-94|% + minLength: 3 + maxLength: 50 + type: string + ticket: + type: string + pattern: ^passwordReset\:.*$ + description: Ticket to reset the password, required if the user is not authenticated + required: + - newPassword + OKResponse: type: string additionalProperties: false diff --git a/go/api/server.gen.go b/go/api/server.gen.go index 39ef170f9..30772835a 100644 --- a/go/api/server.gen.go +++ b/go/api/server.gen.go @@ -61,6 +61,9 @@ type ServerInterface interface { // Send email verification email // (POST /user/email/send-verification-email) PostUserEmailSendVerificationEmail(c *gin.Context) + // Change user password. The user must be authenticated or provide a ticket + // (POST /user/password) + PostUserPassword(c *gin.Context) // Request a password reset. An email with a verification link will be sent to the user's address // (POST /user/password/reset) PostUserPasswordReset(c *gin.Context) @@ -253,6 +256,21 @@ func (siw *ServerInterfaceWrapper) PostUserEmailSendVerificationEmail(c *gin.Con siw.Handler.PostUserEmailSendVerificationEmail(c) } +// PostUserPassword operation middleware +func (siw *ServerInterfaceWrapper) PostUserPassword(c *gin.Context) { + + c.Set(BearerAuthElevatedScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostUserPassword(c) +} + // PostUserPasswordReset operation middleware func (siw *ServerInterfaceWrapper) PostUserPasswordReset(c *gin.Context) { @@ -319,6 +337,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/user/deanonymize", wrapper.PostUserDeanonymize) router.POST(options.BaseURL+"/user/email/change", wrapper.PostUserEmailChange) router.POST(options.BaseURL+"/user/email/send-verification-email", wrapper.PostUserEmailSendVerificationEmail) + router.POST(options.BaseURL+"/user/password", wrapper.PostUserPassword) router.POST(options.BaseURL+"/user/password/reset", wrapper.PostUserPasswordReset) router.GET(options.BaseURL+"/version", wrapper.GetVersion) } @@ -559,6 +578,23 @@ func (response PostUserEmailSendVerificationEmail200JSONResponse) VisitPostUserE return json.NewEncoder(w).Encode(response) } +type PostUserPasswordRequestObject struct { + Body *PostUserPasswordJSONRequestBody +} + +type PostUserPasswordResponseObject interface { + VisitPostUserPasswordResponse(w http.ResponseWriter) error +} + +type PostUserPassword200JSONResponse OKResponse + +func (response PostUserPassword200JSONResponse) VisitPostUserPasswordResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + type PostUserPasswordResetRequestObject struct { Body *PostUserPasswordResetJSONRequestBody } @@ -635,6 +671,9 @@ type StrictServerInterface interface { // Send email verification email // (POST /user/email/send-verification-email) PostUserEmailSendVerificationEmail(ctx context.Context, request PostUserEmailSendVerificationEmailRequestObject) (PostUserEmailSendVerificationEmailResponseObject, error) + // Change user password. The user must be authenticated or provide a ticket + // (POST /user/password) + PostUserPassword(ctx context.Context, request PostUserPasswordRequestObject) (PostUserPasswordResponseObject, error) // Request a password reset. An email with a verification link will be sent to the user's address // (POST /user/password/reset) PostUserPasswordReset(ctx context.Context, request PostUserPasswordResetRequestObject) (PostUserPasswordResetResponseObject, error) @@ -1068,6 +1107,39 @@ func (sh *strictHandler) PostUserEmailSendVerificationEmail(ctx *gin.Context) { } } +// PostUserPassword operation middleware +func (sh *strictHandler) PostUserPassword(ctx *gin.Context) { + var request PostUserPasswordRequestObject + + var body PostUserPasswordJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUserPassword(ctx, request.(PostUserPasswordRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUserPassword") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostUserPasswordResponseObject); ok { + if err := validResponse.VisitPostUserPasswordResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // PostUserPasswordReset operation middleware func (sh *strictHandler) PostUserPasswordReset(ctx *gin.Context) { var request PostUserPasswordResetRequestObject @@ -1129,56 +1201,58 @@ func (sh *strictHandler) GetVersion(ctx *gin.Context) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "///EmyIv+T8AAA==", + "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", + "bNzLfEMAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/go/api/types.gen.go b/go/api/types.gen.go index 41dec5d57..435f3ee0d 100644 --- a/go/api/types.gen.go +++ b/go/api/types.gen.go @@ -30,6 +30,7 @@ const ( InvalidPat ErrorResponseError = "invalid-pat" InvalidRefreshToken ErrorResponseError = "invalid-refresh-token" InvalidRequest ErrorResponseError = "invalid-request" + InvalidTicket ErrorResponseError = "invalid-ticket" LocaleNotAllowed ErrorResponseError = "locale-not-allowed" PasswordInHibpDatabase ErrorResponseError = "password-in-hibp-database" PasswordTooShort ErrorResponseError = "password-too-short" @@ -253,6 +254,15 @@ type UserEmailSendVerificationEmailRequest struct { Options *OptionsRedirectTo `json:"options,omitempty"` } +// UserPasswordRequest defines model for UserPasswordRequest. +type UserPasswordRequest struct { + // NewPassword A password of minimum 3 characters + NewPassword string `json:"newPassword"` + + // Ticket Ticket to reset the password, required if the user is not authenticated + Ticket *string `json:"ticket,omitempty"` +} + // UserPasswordResetRequest defines model for UserPasswordResetRequest. type UserPasswordResetRequest struct { // Email A valid email @@ -293,6 +303,9 @@ type PostUserEmailChangeJSONRequestBody = UserEmailChangeRequest // PostUserEmailSendVerificationEmailJSONRequestBody defines body for PostUserEmailSendVerificationEmail for application/json ContentType. type PostUserEmailSendVerificationEmailJSONRequestBody = UserEmailSendVerificationEmailRequest +// PostUserPasswordJSONRequestBody defines body for PostUserPassword for application/json ContentType. +type PostUserPasswordJSONRequestBody = UserPasswordRequest + // PostUserPasswordResetJSONRequestBody defines body for PostUserPasswordReset for application/json ContentType. type PostUserPasswordResetJSONRequestBody = UserPasswordResetRequest diff --git a/go/controller/controller.go b/go/controller/controller.go index 5687fbf05..2fe30243f 100644 --- a/go/controller/controller.go +++ b/go/controller/controller.go @@ -45,6 +45,7 @@ type DBClientGetUser interface { GetUserByRefreshTokenHash( ctx context.Context, arg sql.GetUserByRefreshTokenHashParams, ) (sql.AuthUser, error) + GetUserByTicket(ctx context.Context, ticket pgtype.Text) (sql.AuthUser, error) } type DBClientInsertUser interface { @@ -66,6 +67,9 @@ type DBClientUpdateUser interface { UpdateUserDeanonymize(ctx context.Context, arg sql.UpdateUserDeanonymizeParams) error UpdateUserLastSeen(ctx context.Context, id uuid.UUID) (pgtype.Timestamptz, error) UpdateUserTicket(ctx context.Context, arg sql.UpdateUserTicketParams) (uuid.UUID, error) + UpdateUserChangePassword( + ctx context.Context, arg sql.UpdateUserChangePasswordParams, + ) (uuid.UUID, error) InsertUserWithSecurityKey( ctx context.Context, arg sql.InsertUserWithSecurityKeyParams, ) (uuid.UUID, error) diff --git a/go/controller/errors.go b/go/controller/errors.go index 4380e933e..92f64b6f6 100644 --- a/go/controller/errors.go +++ b/go/controller/errors.go @@ -36,6 +36,7 @@ var ( ErrUnverifiedUser = &APIError{api.UnverifiedUser} ErrUserNotAnonymous = &APIError{api.UserNotAnonymous} ErrInvalidPat = &APIError{api.InvalidPat} + ErrInvalidTicket = &APIError{api.InvalidTicket} ErrInvalidRequest = &APIError{api.InvalidRequest} ErrSignupDisabled = &APIError{api.SignupDisabled} ErrDisabledEndpoint = &APIError{api.DisabledEndpoint} @@ -77,6 +78,10 @@ func (response ErrorResponse) VisitPostUserEmailChangeResponse(w http.ResponseWr return response.visit(w) } +func (response ErrorResponse) VisitPostUserPasswordResponse(w http.ResponseWriter) error { + return response.visit(w) +} + func (response ErrorResponse) VisitPostUserPasswordResetResponse(w http.ResponseWriter) error { return response.visit(w) } @@ -119,7 +124,8 @@ func isSensitive(err api.ErrorResponseError) bool { api.RoleNotAllowed, api.SignupDisabled, api.UnverifiedUser, - api.InvalidRefreshToken: + api.InvalidRefreshToken, + api.InvalidTicket: return true case api.DefaultRoleMustBeInAllowedRoles, @@ -259,6 +265,12 @@ func (ctrl *Controller) sendError( //nolint:funlen,cyclop Error: err.t, Message: "Invalid or expired refresh token", } + case api.InvalidTicket: + return ErrorResponse{ + Status: http.StatusUnauthorized, + Error: err.t, + Message: "Invalid ticket", + } } return invalidRequest diff --git a/go/controller/mock/controller.go b/go/controller/mock/controller.go index 04f6af931..8bb770c07 100644 --- a/go/controller/mock/controller.go +++ b/go/controller/mock/controller.go @@ -125,6 +125,21 @@ func (mr *MockDBClientGetUserMockRecorder) GetUserByRefreshTokenHash(ctx, arg an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByRefreshTokenHash", reflect.TypeOf((*MockDBClientGetUser)(nil).GetUserByRefreshTokenHash), ctx, arg) } +// GetUserByTicket mocks base method. +func (m *MockDBClientGetUser) GetUserByTicket(ctx context.Context, ticket pgtype.Text) (sql.AuthUser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByTicket", ctx, ticket) + ret0, _ := ret[0].(sql.AuthUser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByTicket indicates an expected call of GetUserByTicket. +func (mr *MockDBClientGetUserMockRecorder) GetUserByTicket(ctx, ticket any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByTicket", reflect.TypeOf((*MockDBClientGetUser)(nil).GetUserByTicket), ctx, ticket) +} + // MockDBClientInsertUser is a mock of DBClientInsertUser interface. type MockDBClientInsertUser struct { ctrl *gomock.Controller @@ -246,6 +261,21 @@ func (mr *MockDBClientUpdateUserMockRecorder) UpdateUserChangeEmail(ctx, arg any return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserChangeEmail", reflect.TypeOf((*MockDBClientUpdateUser)(nil).UpdateUserChangeEmail), ctx, arg) } +// UpdateUserChangePassword mocks base method. +func (m *MockDBClientUpdateUser) UpdateUserChangePassword(ctx context.Context, arg sql.UpdateUserChangePasswordParams) (uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserChangePassword", ctx, arg) + ret0, _ := ret[0].(uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserChangePassword indicates an expected call of UpdateUserChangePassword. +func (mr *MockDBClientUpdateUserMockRecorder) UpdateUserChangePassword(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserChangePassword", reflect.TypeOf((*MockDBClientUpdateUser)(nil).UpdateUserChangePassword), ctx, arg) +} + // UpdateUserDeanonymize mocks base method. func (m *MockDBClientUpdateUser) UpdateUserDeanonymize(ctx context.Context, arg sql.UpdateUserDeanonymizeParams) error { m.ctrl.T.Helper() @@ -401,6 +431,21 @@ func (mr *MockDBClientMockRecorder) GetUserByRefreshTokenHash(ctx, arg any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByRefreshTokenHash", reflect.TypeOf((*MockDBClient)(nil).GetUserByRefreshTokenHash), ctx, arg) } +// GetUserByTicket mocks base method. +func (m *MockDBClient) GetUserByTicket(ctx context.Context, ticket pgtype.Text) (sql.AuthUser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByTicket", ctx, ticket) + ret0, _ := ret[0].(sql.AuthUser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByTicket indicates an expected call of GetUserByTicket. +func (mr *MockDBClientMockRecorder) GetUserByTicket(ctx, ticket any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByTicket", reflect.TypeOf((*MockDBClient)(nil).GetUserByTicket), ctx, ticket) +} + // GetUserRoles mocks base method. func (m *MockDBClient) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]sql.AuthUserRole, error) { m.ctrl.T.Helper() @@ -521,6 +566,21 @@ func (mr *MockDBClientMockRecorder) UpdateUserChangeEmail(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserChangeEmail", reflect.TypeOf((*MockDBClient)(nil).UpdateUserChangeEmail), ctx, arg) } +// UpdateUserChangePassword mocks base method. +func (m *MockDBClient) UpdateUserChangePassword(ctx context.Context, arg sql.UpdateUserChangePasswordParams) (uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserChangePassword", ctx, arg) + ret0, _ := ret[0].(uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserChangePassword indicates an expected call of UpdateUserChangePassword. +func (mr *MockDBClientMockRecorder) UpdateUserChangePassword(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserChangePassword", reflect.TypeOf((*MockDBClient)(nil).UpdateUserChangePassword), ctx, arg) +} + // UpdateUserDeanonymize mocks base method. func (m *MockDBClient) UpdateUserDeanonymize(ctx context.Context, arg sql.UpdateUserDeanonymizeParams) error { m.ctrl.T.Helper() diff --git a/go/controller/post_user_password.go b/go/controller/post_user_password.go new file mode 100644 index 000000000..b4d8af0f1 --- /dev/null +++ b/go/controller/post_user_password.go @@ -0,0 +1,70 @@ +package controller + +import ( + "context" + "log/slog" + + "github.com/golang-jwt/jwt/v5" + "github.com/nhost/hasura-auth/go/api" + "github.com/nhost/hasura-auth/go/middleware" +) + +func (ctrl *Controller) postUserPasswordAuthenticated( //nolint:ireturn + ctx context.Context, + request api.PostUserPasswordRequestObject, + jwtToken *jwt.Token, + logger *slog.Logger, +) (api.PostUserPasswordResponseObject, error) { + logger.Debug("authenticated request") + + userID, err := ctrl.wf.jwtGetter.GetUserID(jwtToken) + if err != nil { + logger.Error("error getting user id from jwt token", logError(err)) + return ctrl.sendError(ErrInvalidRequest), nil + } + + if _, apiErr := ctrl.wf.GetUser(ctx, userID, logger); apiErr != nil { + return ctrl.sendError(apiErr), nil + } + + if apiErr := ctrl.wf.ChangePassword(ctx, userID, request.Body.NewPassword, logger); apiErr != nil { + return ctrl.sendError(apiErr), nil + } + + return api.PostUserPassword200JSONResponse(api.OK), nil +} + +func (ctrl *Controller) postUserPasswordUnauthenticated( //nolint:ireturn + ctx context.Context, + request api.PostUserPasswordRequestObject, + logger *slog.Logger, +) (api.PostUserPasswordResponseObject, error) { + logger.Debug("unauthenticated request") + if request.Body.Ticket == nil { + return ctrl.sendError(ErrInvalidRequest), nil + } + + user, apiErr := ctrl.wf.GetUserByTicket(ctx, *request.Body.Ticket, logger) + if apiErr != nil { + return ctrl.sendError(apiErr), nil + } + + if apiErr := ctrl.wf.ChangePassword(ctx, user.ID, request.Body.NewPassword, logger); apiErr != nil { + return ctrl.sendError(apiErr), nil + } + + return api.PostUserPassword200JSONResponse(api.OK), nil +} + +func (ctrl *Controller) PostUserPassword( //nolint:ireturn + ctx context.Context, + request api.PostUserPasswordRequestObject, +) (api.PostUserPasswordResponseObject, error) { + logger := middleware.LoggerFromContext(ctx) + jwtToken, ok := ctrl.wf.jwtGetter.FromContext(ctx) + if ok { + return ctrl.postUserPasswordAuthenticated(ctx, request, jwtToken, logger) + } + + return ctrl.postUserPasswordUnauthenticated(ctx, request, logger) +} diff --git a/go/controller/post_user_password_test.go b/go/controller/post_user_password_test.go new file mode 100644 index 000000000..d09e00a9a --- /dev/null +++ b/go/controller/post_user_password_test.go @@ -0,0 +1,461 @@ +package controller_test + +import ( + "context" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/nhost/hasura-auth/go/api" + "github.com/nhost/hasura-auth/go/controller" + "github.com/nhost/hasura-auth/go/controller/mock" + "github.com/nhost/hasura-auth/go/sql" + "github.com/nhost/hasura-auth/go/testhelpers" + "go.uber.org/mock/gomock" +) + +func TestPostUserdReset(t *testing.T) { //nolint:maintidx + t.Parallel() + + userID := uuid.MustParse("db477732-48fa-4289-b694-2886a646b6eb") + + jwtTokenFn := func() *jwt.Token { + return &jwt.Token{ + Raw: "", + Method: jwt.SigningMethodHS256, + Header: map[string]any{ + "alg": "HS256", + "typ": "JWT", + }, + Claims: jwt.MapClaims{ + "exp": float64(time.Now().Add(900 * time.Second).Unix()), + "https://hasura.io/jwt/claims": map[string]any{ + "x-hasura-allowed-roles": []any{"anonymous"}, + "x-hasura-default-role": "anonymous", + "x-hasura-user-id": "db477732-48fa-4289-b694-2886a646b6eb", + "x-hasura-user-is-anonymous": "true", + }, + "iat": float64(time.Now().Unix()), + "iss": "hasura-auth", + "sub": "db477732-48fa-4289-b694-2886a646b6eb", + }, + Signature: []byte{}, + Valid: true, + } + } + + cases := []testRequest[api.PostUserPasswordRequestObject, api.PostUserPasswordResponseObject]{ + { + name: "ticket", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUserByTicket( + gomock.Any(), + sql.Text("passwordReset:ticket"), + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + }, nil) + + mock.EXPECT().UpdateUserChangePassword( + gomock.Any(), + testhelpers.GomockCmpOpts( + sql.UpdateUserChangePasswordParams{ + ID: userID, + PasswordHash: sql.Text("password"), + }, + cmpopts.IgnoreFields( + sql.UpdateUserChangePasswordParams{}, //nolint:exhaustruct + "PasswordHash", + ), + ), + ).Return(userID, nil) + + return mock + }, + jwtTokenFn: nil, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: ptr("passwordReset:ticket"), + }, + }, + expectedResponse: api.PostUserPassword200JSONResponse(api.OK), + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "ticket - user not found", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUserByTicket( + gomock.Any(), + sql.Text("passwordReset:ticket"), + ).Return( + sql.AuthUser{}, //nolint:exhaustruct + pgx.ErrNoRows) + + return mock + }, + jwtTokenFn: nil, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: ptr("passwordReset:ticket"), + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "invalid-ticket", + Message: "Invalid ticket", + Status: 401, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "ticket - user disabled", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUserByTicket( + gomock.Any(), + sql.Text("passwordReset:ticket"), + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + Disabled: true, + }, nil) + + return mock + }, + jwtTokenFn: nil, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: ptr("passwordReset:ticket"), + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "disabled-user", + Message: "User is disabled", + Status: 401, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "ticket - user anonymous", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUserByTicket( + gomock.Any(), + sql.Text("passwordReset:ticket"), + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + IsAnonymous: true, + }, nil) + + return mock + }, + jwtTokenFn: nil, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: ptr("passwordReset:ticket"), + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "forbidden-anonymous", + Message: "Forbidden, user is anonymous.", + Status: 403, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "ticket - password length", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUserByTicket( + gomock.Any(), + sql.Text("passwordReset:ticket"), + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + }, nil) + + return mock + }, + jwtTokenFn: nil, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "p", + Ticket: ptr("passwordReset:ticket"), + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "password-too-short", + Message: "Password is too short", + Status: 400, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "auth header", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUser( + gomock.Any(), + userID, + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + }, nil) + + mock.EXPECT().UpdateUserChangePassword( + gomock.Any(), + testhelpers.GomockCmpOpts( + sql.UpdateUserChangePasswordParams{ + ID: userID, + PasswordHash: sql.Text("password"), + }, + cmpopts.IgnoreFields( + sql.UpdateUserChangePasswordParams{}, //nolint:exhaustruct + "PasswordHash", + ), + ), + ).Return(userID, nil) + + return mock + }, + jwtTokenFn: jwtTokenFn, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: nil, + }, + }, + expectedResponse: api.PostUserPassword200JSONResponse(api.OK), + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "no header and no ticket", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + return mock + }, + jwtTokenFn: nil, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: nil, + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "invalid-request", + Message: "The request payload is incorrect", + Status: 400, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "auth header - user not found", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUser( + gomock.Any(), + userID, + ).Return(sql.AuthUser{}, //nolint:exhaustruct + pgx.ErrNoRows, + ) + + return mock + }, + jwtTokenFn: jwtTokenFn, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: nil, + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "invalid-email-password", + Message: "Incorrect email or password", + Status: 401, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "auth header - user disabled", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUser( + gomock.Any(), + userID, + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + Disabled: true, + }, nil) + + return mock + }, + jwtTokenFn: jwtTokenFn, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: nil, + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "disabled-user", + Message: "User is disabled", + Status: 401, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "auth header - anonymous", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUser( + gomock.Any(), + userID, + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + IsAnonymous: true, + }, nil) + + return mock + }, + jwtTokenFn: jwtTokenFn, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "password", + Ticket: nil, + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "forbidden-anonymous", + Message: "Forbidden, user is anonymous.", + Status: 403, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + + { + name: "auth header - password length", + config: getConfig, + db: func(ctrl *gomock.Controller) controller.DBClient { + mock := mock.NewMockDBClient(ctrl) + + mock.EXPECT().GetUser( + gomock.Any(), + userID, + ).Return(sql.AuthUser{ //nolint:exhaustruct + ID: userID, + Email: sql.Text("user@acme.local"), + }, nil) + + return mock + }, + jwtTokenFn: jwtTokenFn, + request: api.PostUserPasswordRequestObject{ + Body: &api.PostUserPasswordJSONRequestBody{ + NewPassword: "p", + Ticket: nil, + }, + }, + expectedResponse: controller.ErrorResponse{ + Error: "password-too-short", + Message: "Password is too short", + Status: 400, + }, + emailer: nil, + customClaimer: nil, + expectedJWT: nil, + hibp: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + + c, jwtGetter := getController(t, ctrl, tc.config, tc.db, getControllerOpts{ + customClaimer: nil, + emailer: nil, + hibp: nil, + }) + + ctx := context.Background() + if tc.jwtTokenFn != nil { + ctx = jwtGetter.ToContext(context.Background(), tc.jwtTokenFn()) + } + assertRequest( + ctx, t, c.PostUserPassword, tc.request, tc.expectedResponse, + ) + }) + } +} diff --git a/go/controller/workflows.go b/go/controller/workflows.go index 07a185672..96867b5d7 100644 --- a/go/controller/workflows.go +++ b/go/controller/workflows.go @@ -296,6 +296,28 @@ func (wf *Workflows) GetUserByRefreshTokenHash( return user, nil } +func (wf *Workflows) GetUserByTicket( + ctx context.Context, + ticket string, + logger *slog.Logger, +) (sql.AuthUser, *APIError) { + user, err := wf.db.GetUserByTicket(ctx, sql.Text(ticket)) + if errors.Is(err, pgx.ErrNoRows) { + logger.Warn("user not found") + return sql.AuthUser{}, ErrInvalidTicket //nolint:exhaustruct + } + if err != nil { + logger.Error("could not get user by ticket", logError(err)) + return sql.AuthUser{}, ErrInternalServerError //nolint:exhaustruct + } + + if apiErr := wf.ValidateUser(user, logger); apiErr != nil { + return user, apiErr + } + + return user, nil +} + func pgtypeTextToOAPIEmail(pgemail pgtype.Text) *types.Email { var email *types.Email if pgemail.Valid { @@ -534,6 +556,36 @@ func (wf *Workflows) ChangeEmail( return user, nil } +func (wf *Workflows) ChangePassword( + ctx context.Context, + userID uuid.UUID, + newPassord string, + logger *slog.Logger, +) *APIError { + if err := wf.ValidatePassword(ctx, newPassord, logger); err != nil { + return err + } + + hashedPassword, err := hashPassword(newPassord) + if err != nil { + logger.Error("error hashing password", logError(err)) + return ErrInternalServerError + } + + if _, err := wf.db.UpdateUserChangePassword( + ctx, + sql.UpdateUserChangePasswordParams{ + ID: userID, + PasswordHash: sql.Text(hashedPassword), + }, + ); err != nil { + logger.Error("error updating user password", logError(err)) + return ErrInternalServerError + } + + return nil +} + func (wf *Workflows) SendEmail( ctx context.Context, to string, diff --git a/go/sql/query.sql b/go/sql/query.sql index 53de9c750..86afebb3c 100644 --- a/go/sql/query.sql +++ b/go/sql/query.sql @@ -19,6 +19,17 @@ WITH refresh_token AS ( SELECT * FROM auth.users WHERE id = (SELECT user_id FROM refresh_token) LIMIT 1; +-- name: GetUserByTicket :one +WITH selected_user AS ( + SELECT * FROM auth.users + WHERE ticket = $1 AND ticket_expires_at > now() + LIMIT 1 +) +UPDATE auth.users +SET ticket = NULL, ticket_expires_at = now() +WHERE id = (SELECT id FROM selected_user) +RETURNING *; + -- name: InsertUser :one WITH inserted_user AS ( INSERT INTO auth.users ( @@ -179,6 +190,12 @@ SET (ticket, ticket_expires_at, new_email) = ($2, $3, $4) WHERE id = $1 RETURNING *; +-- name: UpdateUserChangePassword :one +UPDATE auth.users +SET password_hash = $2 +WHERE id = $1 +RETURNING id; + -- name: CountSecurityKeysUser :one SELECT COUNT(*) FROM auth.user_security_keys WHERE user_id = $1; diff --git a/go/sql/query.sql.go b/go/sql/query.sql.go index 07ce40609..9beb04066 100644 --- a/go/sql/query.sql.go +++ b/go/sql/query.sql.go @@ -168,6 +168,51 @@ func (q *Queries) GetUserByRefreshTokenHash(ctx context.Context, arg GetUserByRe return i, err } +const getUserByTicket = `-- name: GetUserByTicket :one +WITH selected_user AS ( + SELECT id, created_at, updated_at, last_seen, disabled, display_name, avatar_url, locale, email, phone_number, password_hash, email_verified, phone_number_verified, new_email, otp_method_last_used, otp_hash, otp_hash_expires_at, default_role, is_anonymous, totp_secret, active_mfa_type, ticket, ticket_expires_at, metadata, webauthn_current_challenge FROM auth.users + WHERE ticket = $1 AND ticket_expires_at > now() + LIMIT 1 +) +UPDATE auth.users +SET ticket = NULL, ticket_expires_at = now() +WHERE id = (SELECT id FROM selected_user) +RETURNING id, created_at, updated_at, last_seen, disabled, display_name, avatar_url, locale, email, phone_number, password_hash, email_verified, phone_number_verified, new_email, otp_method_last_used, otp_hash, otp_hash_expires_at, default_role, is_anonymous, totp_secret, active_mfa_type, ticket, ticket_expires_at, metadata, webauthn_current_challenge +` + +func (q *Queries) GetUserByTicket(ctx context.Context, dollar_1 pgtype.Text) (AuthUser, error) { + row := q.db.QueryRow(ctx, getUserByTicket, dollar_1) + var i AuthUser + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastSeen, + &i.Disabled, + &i.DisplayName, + &i.AvatarUrl, + &i.Locale, + &i.Email, + &i.PhoneNumber, + &i.PasswordHash, + &i.EmailVerified, + &i.PhoneNumberVerified, + &i.NewEmail, + &i.OtpMethodLastUsed, + &i.OtpHash, + &i.OtpHashExpiresAt, + &i.DefaultRole, + &i.IsAnonymous, + &i.TotpSecret, + &i.ActiveMfaType, + &i.Ticket, + &i.TicketExpiresAt, + &i.Metadata, + &i.WebauthnCurrentChallenge, + ) + return i, err +} + const getUserRoles = `-- name: GetUserRoles :many SELECT id, created_at, user_id, role FROM auth.user_roles WHERE user_id = $1 @@ -626,6 +671,25 @@ func (q *Queries) UpdateUserChangeEmail(ctx context.Context, arg UpdateUserChang return i, err } +const updateUserChangePassword = `-- name: UpdateUserChangePassword :one +UPDATE auth.users +SET password_hash = $2 +WHERE id = $1 +RETURNING id +` + +type UpdateUserChangePasswordParams struct { + ID uuid.UUID + PasswordHash pgtype.Text +} + +func (q *Queries) UpdateUserChangePassword(ctx context.Context, arg UpdateUserChangePasswordParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, updateUserChangePassword, arg.ID, arg.PasswordHash) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + const updateUserDeanonymize = `-- name: UpdateUserDeanonymize :exec WITH inserted_user AS ( UPDATE auth.users diff --git a/src/routes/user/index.ts b/src/routes/user/index.ts index 145e79764..28ba8a4f4 100644 --- a/src/routes/user/index.ts +++ b/src/routes/user/index.ts @@ -6,7 +6,6 @@ import { authenticationGate } from '@/middleware/auth'; import { userMFAHandler, userMfaSchema } from './mfa'; import { userHandler } from './user'; -import { userPasswordHandler, userPasswordSchema } from './password'; import { userProviderTokensHandler, userProviderTokensSchema, @@ -33,23 +32,6 @@ router.get( aw(userHandler), ); -/** - * POST /user/password - * @summary Set a new password - * @param {UserPasswordSchema} request.body.required - * @return {string} 200 - The password has been successfully changed - tapplication/json - * @return {InvalidRequestError} 400 - The payload is invalid - application/json - * @return {UnauthenticatedUserError} 401 - User is not authenticated - application/json - * @security BearerAuth - * @tags User management - */ -router.post( - '/user/password', - bodyValidator(userPasswordSchema), - // authenticationGate(true, false, (req) => req.body.ticket !== undefined), // this is done in the handler because the handler has an auhtenticated and unauthenticated mode............. - aw(userPasswordHandler) -); - /** * POST /user/mfa * @summary Activate/deactivate Multi-factor authentication diff --git a/src/routes/user/password-reset.ts b/src/routes/user/password-reset.ts deleted file mode 100644 index b222f9287..000000000 --- a/src/routes/user/password-reset.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { RequestHandler } from 'express'; -import { v4 as uuidv4 } from 'uuid'; -import { ReasonPhrases } from 'http-status-codes'; - -import { sendEmail } from '@/email'; -import { - gqlSdk, - getUserByEmail, - generateTicketExpiresAt, - ENV, - createEmailRedirectionLink, -} from '@/utils'; -import { sendError } from '@/errors'; -import { Joi, email, redirectTo } from '@/validation'; -import { EMAIL_TYPES } from '@/types'; - -export const userPasswordResetSchema = Joi.object({ - email: email.required(), - options: Joi.object({ - redirectTo, - }).default(), -}).meta({ className: 'UserPasswordResetSchema' }); - -export const userPasswordResetHandler: RequestHandler< - {}, - {}, - { - email: string; - options: { - redirectTo: string; - }; - } -> = async (req, res) => { - const { - email, - options: { redirectTo }, - } = req.body; - const user = await getUserByEmail(email); - - if (!user || user.disabled) { - return sendError(res, 'user-not-found'); - } - - const ticket = `${EMAIL_TYPES.PASSWORD_RESET}:${uuidv4()}`; - const ticketExpiresAt = generateTicketExpiresAt(60 * 60); // 1 hour - - await gqlSdk.updateUser({ - id: user.id, - user: { - ticket, - ticketExpiresAt, - }, - }); - - const template = 'password-reset'; - const link = createEmailRedirectionLink( - EMAIL_TYPES.PASSWORD_RESET, - ticket, - redirectTo - ); - await sendEmail({ - template, - locals: { - link, - displayName: user.displayName, - email, - newEmail: user.newEmail, - ticket, - redirectTo: encodeURIComponent(redirectTo), - locale: user.locale ?? ENV.AUTH_LOCALE_DEFAULT, - serverUrl: ENV.AUTH_SERVER_URL, - clientUrl: ENV.AUTH_CLIENT_URL, - }, - message: { - to: email, - headers: { - 'x-ticket': { - prepared: true, - value: ticket, - }, - 'x-redirect-to': { - prepared: true, - value: redirectTo, - }, - 'x-email-template': { - prepared: true, - value: template, - }, - 'x-link': { - prepared: true, - value: link, - }, - }, - }, - }); - - return res.json(ReasonPhrases.OK); -}; diff --git a/src/routes/user/password.ts b/src/routes/user/password.ts deleted file mode 100644 index 529d7f58c..000000000 --- a/src/routes/user/password.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { RequestHandler } from 'express'; -import { ReasonPhrases } from 'http-status-codes'; - -import { failsElevatedCheck } from '@/middleware/auth'; - -import { gqlSdk, hashPassword, getUserByTicket } from '@/utils'; -import { sendError } from '@/errors'; -import { Joi, password } from '@/validation'; - -export const userPasswordSchema = Joi.object({ - newPassword: password.required(), - ticket: Joi.string(), -}).meta({ className: 'UserPasswordSchema' }); - -export const userPasswordHandler: RequestHandler< - {}, - {}, - { newPassword: string; ticket?: string } -> = async (req, res) => { - const { ticket } = req.body; - - let user; - if (ticket) { - user = await getUserByTicket(ticket); - if (!user) { - return sendError(res, 'invalid-ticket'); - } - } else { - if (!req.auth?.userId) { - return sendError(res, 'unauthenticated-user'); - } - - if (await failsElevatedCheck(req.auth)) { - return sendError(res, 'elevated-claim-required'); - } - - user = (await gqlSdk.user({ id: req.auth?.userId })).user; - } - - if (!user) { - return sendError(res, 'user-not-found'); - } - - if (user.isAnonymous) { - return sendError(res, 'forbidden-anonymous'); - } - const { newPassword } = req.body; - const passwordHash = await hashPassword(newPassword); - - await gqlSdk.updateUser({ - id: user.id, - user: { - passwordHash, - ticket: ticket ? null : undefined, // Hasura does not update when variable is undefined - }, - }); - return res.json(ReasonPhrases.OK); -}; diff --git a/test/routes/user/password.test.ts b/test/routes/user/password.test.ts index 0af07d0d2..689d0dfae 100644 --- a/test/routes/user/password.test.ts +++ b/test/routes/user/password.test.ts @@ -176,7 +176,7 @@ describe('user password', () => { // use ticket to reset password await request .post('/user/password') - .send({ newPassword, ticket: 'inavlid-ticket' }) + .send({ newPassword, ticket: 'passwordReset:inavlid-ticket' }) .expect(StatusCodes.UNAUTHORIZED); });