diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala index d9e2a6f98..964ee2c95 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala @@ -12,6 +12,6 @@ object Fail { case class NotFound(what: String) extends Fail case class Conflict(msg: String) extends Fail case class IncorrectInput(msg: String) extends Fail - case object Unauthorized extends Fail + case class Unauthorized(msg: String) extends Fail case object Forbidden extends Fail } diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala b/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala index 3c8cf358e..29b171b6e 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala @@ -46,7 +46,7 @@ class Http() extends Tapir with TapirJsonCirce with TapirSchemas with StrictLogg case Fail.Conflict(msg) => (StatusCode.Conflict, msg) case Fail.IncorrectInput(msg) => (StatusCode.BadRequest, msg) case Fail.Forbidden => (StatusCode.Forbidden, "Forbidden") - case Fail.Unauthorized => (StatusCode.Unauthorized, "Unauthorized") + case Fail.Unauthorized(msg) => (StatusCode.Unauthorized, msg) case _ => InternalServerError } diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala index df6b1fbe8..1db7c93e4 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala @@ -27,7 +27,9 @@ class PasswordResetService( def forgotPassword(loginOrEmail: String): ConnectionIO[Unit] = { userModel.findByLoginOrEmail(loginOrEmail.lowerCased).flatMap { - case None => Fail.NotFound("user").raiseError[ConnectionIO, Unit] + case None => + logger.debug(s"Could not find user with $loginOrEmail login/email") + ().pure[ConnectionIO] case Some(user) => createCode(user).flatMap(sendCode(user, _)) } diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala index 50251086f..74ef43c2d 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala @@ -38,7 +38,7 @@ class Auth[T]( case None => logger.debug(s"Auth failed for: ${authTokenOps.tokenName} $id") // random sleep to prevent timing attacks - Timer[Task].sleep(random.nextInt(1000).millis) >> Task.raiseError(Fail.Unauthorized) + Timer[Task].sleep(random.nextInt(1000).millis) >> Task.raiseError(Fail.Unauthorized("Unauthorized")) case Some(token) => val delete = if (authTokenOps.deleteWhenValid) authTokenOps.delete(token).transact(xa) else Task.unit delete >> Task.now(authTokenOps.userId(token)) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala index 92c7e357b..2152051ea 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala @@ -26,6 +26,7 @@ class UserService( private val LoginAlreadyUsed = "Login already in use!" private val EmailAlreadyUsed = "E-mail already in use!" + private val IncorrectLoginOrPassword = "Incorrect login/email or password" def registerNewUser(login: String, email: String, password: String): ConnectionIO[ApiKey] = { def failIfDefined(op: ConnectionIO[Option[User]], msg: String): ConnectionIO[Unit] = { @@ -67,7 +68,7 @@ class UserService( def login(loginOrEmail: String, password: String, apiKeyValid: Option[Duration]): ConnectionIO[ApiKey] = for { user <- userOrNotFound(userModel.findByLoginOrEmail(loginOrEmail.lowerCased)) - _ <- verifyPassword(user, password) + _ <- verifyPassword(user, password, validationErrorMsg = IncorrectLoginOrPassword) apiKey <- apiKeyService.create(user.id, apiKeyValid.getOrElse(config.defaultApiKeyValid)) } yield apiKey @@ -100,7 +101,7 @@ class UserService( def changePassword(userId: Id @@ User, currentPassword: String, newPassword: String): ConnectionIO[Unit] = for { user <- userOrNotFound(userModel.findById(userId)) - _ <- verifyPassword(user, currentPassword) + _ <- verifyPassword(user, currentPassword, validationErrorMsg = "Incorrect current password") _ = logger.debug(s"Changing password for user: $userId") _ <- userModel.updatePassword(userId, User.hashPassword(newPassword)) } yield () @@ -108,15 +109,15 @@ class UserService( private def userOrNotFound(op: ConnectionIO[Option[User]]): ConnectionIO[User] = { op.flatMap { case Some(user) => user.pure[ConnectionIO] - case None => Fail.NotFound("user").raiseError[ConnectionIO, User] + case None => Fail.Unauthorized(IncorrectLoginOrPassword).raiseError[ConnectionIO, User] } } - private def verifyPassword(user: User, password: String): ConnectionIO[Unit] = { + private def verifyPassword(user: User, password: String, validationErrorMsg: String): ConnectionIO[Unit] = { if (user.verifyPassword(password) == Verified) { ().pure[ConnectionIO] } else { - Fail.Unauthorized.raiseError[ConnectionIO, Unit] + Fail.Unauthorized(validationErrorMsg).raiseError[ConnectionIO, Unit] } } } @@ -139,7 +140,7 @@ object UserRegisterValidator { """^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r private def validEmail(email: String) = - if (emailRegex.findFirstMatchIn(email).isDefined) ValidationOk else Left("Invalid e-mail!") + if (emailRegex.findFirstMatchIn(email).isDefined) ValidationOk else Left("Invalid e-mail format!") private def validPassword(password: String) = if (password.nonEmpty) ValidationOk else Left("Password cannot be empty!") diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala index 0b71b9636..291ae9725 100644 --- a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala +++ b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala @@ -18,11 +18,13 @@ import sttp.client3.SttpBackend class PasswordResetApiTest extends BaseTest with TestEmbeddedPostgres with Eventually { lazy val modules: MainModule = new MainModule { override def xa: Transactor[Task] = currentDb.xa + override lazy val baseSttpBackend: SttpBackend[Task, Any] = SttpBackendStub(TaskMonadAsyncError) override lazy val config: Config = TestConfig } val requests = new Requests(modules) + import requests._ "/passwordreset" should "reset the password" in { @@ -35,7 +37,9 @@ class PasswordResetApiTest extends BaseTest with TestEmbeddedPostgres with Event response1.shouldDeserializeTo[ForgotPassword_OUT] // then - val code = eventually { codeSentToEmail(email) } + val code = eventually { + codeSentToEmail(email) + } // when val response2 = resetPassword(code, newPassword) @@ -57,7 +61,9 @@ class PasswordResetApiTest extends BaseTest with TestEmbeddedPostgres with Event response1.shouldDeserializeTo[ForgotPassword_OUT] // then - val code = eventually { codeSentToEmail(email) } + val code = eventually { + codeSentToEmail(email) + } // when resetPassword(code, newPassword).shouldDeserializeTo[PasswordReset_OUT] @@ -68,6 +74,20 @@ class PasswordResetApiTest extends BaseTest with TestEmbeddedPostgres with Event loginUser(login, newerPassword, None).status shouldBe Status.Unauthorized } + "/passwordreset/forgot" should "end up with Ok HTTP status code and do not send and email if user was not found" in { + // given + val RegisteredUser(_, email, _, _) = newRegisteredUsed() + + // when + val response1 = forgotPassword("wrongUser") + + // then + response1.status shouldBe Status.Ok + eventually { + codeWasNotSentToEmail(email) + } + } + "/passwordreset" should "not reset the password given an invalid code" in { // given val RegisteredUser(login, _, password, _) = newRegisteredUsed() @@ -107,6 +127,16 @@ class PasswordResetApiTest extends BaseTest with TestEmbeddedPostgres with Event .getOrElse(throw new IllegalStateException(s"No code found in: $emailData")) } + def codeWasNotSentToEmail(email: String): Unit = { + modules.emailService.sendBatch().unwrap + + val maybeEmail = DummyEmailSender.findSentEmail(email, "SoftwareMill Bootzooka password reset") + maybeEmail match { + case Some(emailData) => throw new IllegalStateException(s"There should be no password reset email sent to $email, but instead found $emailData") + case None => () + } + } + def codeFromResetPasswordEmail(email: String): Option[String] = { val regexp = "code=([\\w]*)".r regexp.findFirstMatchIn(email).map(_.group(1)) diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala index db95e8d2d..69393bae0 100644 --- a/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala +++ b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala @@ -131,6 +131,26 @@ class UserApiTest extends BaseTest with TestEmbeddedPostgres with Eventually { getUser(apiKey).status shouldBe Status.Unauthorized } + "/user/login" should "respond with 403 HTTP status code and 'Incorrect login/email or password' message if user was not found" in { + // given + val RegisteredUser(_, _, password, _) = newRegisteredUsed() + + // when + val response = loginUser("unknownLogin", password, Some(3)) + response.status shouldBe Status.Unauthorized + response.shouldDeserializeToError shouldBe "Incorrect login/email or password" + } + + "/user/login" should "respond with 403 HTTP status code and 'Incorrect login/email or password' message if password is incorrect for user" in { + // given + val RegisteredUser(login, _, _, _) = newRegisteredUsed() + + // when + val response = loginUser(login, "wrongPassword", Some(3)) + response.status shouldBe Status.Unauthorized + response.shouldDeserializeToError shouldBe "Incorrect login/email or password" + } + "/user/info" should "respond with 403 if the token is invalid" in { getUser("invalid").status shouldBe Status.Unauthorized } @@ -158,7 +178,9 @@ class UserApiTest extends BaseTest with TestEmbeddedPostgres with Eventually { val response1 = changePassword(apiKey, "invalid", newPassword) // then - response1.shouldDeserializeToError + response1.status shouldBe Status.Unauthorized + response1.shouldDeserializeToError shouldBe "Incorrect current password" + loginUser(login, password, None).status shouldBe Status.Ok loginUser(login, newPassword, None).status shouldBe Status.Unauthorized } diff --git a/ui/src/pages/Login/Login.test.tsx b/ui/src/pages/Login/Login.test.tsx index 65e120405..f0e52e766 100644 --- a/ui/src/pages/Login/Login.test.tsx +++ b/ui/src/pages/Login/Login.test.tsx @@ -92,28 +92,3 @@ test("handles login error", async () => { expect(dispatch).not.toBeCalled(); expect(getByText("Test Error")).toBeInTheDocument(); }); - -test("handles login error 404", async () => { - (userService.login as jest.Mock).mockRejectedValueOnce({ response: { status: 404 } }); - - const { getByLabelText, getByText, findByRole } = render( - - - - - - ); - - fireEvent.blur(getByLabelText("Login or email")); - fireEvent.change(getByLabelText("Login or email"), { target: { value: "test-login" } }); - fireEvent.blur(getByLabelText("Login or email")); - fireEvent.change(getByLabelText("Password"), { target: { value: "test-password" } }); - fireEvent.blur(getByLabelText("Password")); - fireEvent.click(getByText("Sign In")); - - await findByRole("loader"); - - expect(userService.login).toBeCalledWith({ loginOrEmail: "test-login", password: "test-password" }); - expect(dispatch).not.toBeCalled(); - expect(getByText("Incorrect login/email or password!")).toBeInTheDocument(); -}); diff --git a/ui/src/pages/Login/Login.tsx b/ui/src/pages/Login/Login.tsx index 54d7589aa..aa349fe6d 100644 --- a/ui/src/pages/Login/Login.tsx +++ b/ui/src/pages/Login/Login.tsx @@ -33,11 +33,6 @@ const Login: React.FC = () => { userService .login(values) .then(({ apiKey }) => dispatch({ type: "SET_API_KEY", apiKey })) - .catch((error) => { - if (error?.response?.status === 404) throw new Error("Incorrect login/email or password!"); - - throw error; - }) ); if (loggedIn) return ;