Skip to content

Commit

Permalink
Migrate PUT admin/iri/<userIri>/Password to Tapir
Browse files Browse the repository at this point in the history
  • Loading branch information
seakayone committed Feb 13, 2024
1 parent db02072 commit 8167d42
Show file tree
Hide file tree
Showing 8 changed files with 182 additions and 235 deletions.
Expand Up @@ -805,6 +805,21 @@ class UsersADME2ESpec
response2.status should be(StatusCodes.OK)
}

"return 'Forbidden' when updating another user's password if requesting user is not a SystemAdmin" in {
val changeUserPasswordRequest: String =
s"""{
| "requesterPassword": "test",
| "newPassword": "test123456"
|}""".stripMargin

val request1 = Put(
baseApiUrl + s"/admin/users/iri/${URLEncoder.encode(customUserIri, "utf-8")}/Password",
HttpEntity(ContentTypes.`application/json`, changeUserPasswordRequest)
) ~> addCredentials(BasicHttpCredentials(normalUser.email, "test"))
val response1: HttpResponse = singleAwaitingRequest(request1)
response1.status should be(StatusCodes.Forbidden)
}

"return 'BadRequest' if new password in change password request is missing" in {
val changeUserPasswordRequest: String =
s"""{
Expand Down
Expand Up @@ -26,6 +26,7 @@ import org.knora.webapi.routing.Authenticator
import org.knora.webapi.routing.UnsafeZioRun
import org.knora.webapi.sharedtestdata.SharedTestDataADM
import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest
import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest
import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest
import org.knora.webapi.slice.admin.domain.model.*
import org.knora.webapi.util.ZioScalaTestUtil.assertFailsWithA
Expand Down Expand Up @@ -297,21 +298,18 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender {
"UPDATE the user's password (by himself)" in {
val requesterPassword = Password.unsafeFrom("test")
val newPassword = Password.unsafeFrom("test123456")
appActor ! UserChangePasswordRequestADM(
userIri = SharedTestDataADM.normalUser.id,
userUpdatePasswordPayload = UserUpdatePasswordPayloadADM(
requesterPassword = requesterPassword,
newPassword = newPassword
),
requestingUser = SharedTestDataADM.normalUser,
apiRequestID = UUID.randomUUID()
UnsafeZioRun.runOrThrow(
UsersResponder.changePassword(
SharedTestDataADM.normalUser.userIri,
PasswordChangeRequest(requesterPassword, newPassword),
SharedTestDataADM.normalUser,
UUID.randomUUID
)
)

expectMsgType[UserOperationResponseADM](timeout)

// need to be able to authenticate credentials with new password
val cedId = CredentialsIdentifier.UsernameIdentifier(Username.unsafeFrom(normalUser.username))
val credentials = KnoraCredentialsV2.KnoraPasswordCredentialsV2(cedId, "test123456")
val credentials = KnoraCredentialsV2.KnoraPasswordCredentialsV2(cedId, newPassword.value)
val resF = UnsafeZioRun.runToFuture(Authenticator.authenticateCredentialsV2(Some(credentials)))

resF map { res => assert(res) }
Expand All @@ -321,18 +319,15 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender {
val requesterPassword = Password.unsafeFrom("test")
val newPassword = Password.unsafeFrom("test654321")

appActor ! UserChangePasswordRequestADM(
userIri = SharedTestDataADM.normalUser.id,
userUpdatePasswordPayload = UserUpdatePasswordPayloadADM(
requesterPassword = requesterPassword,
newPassword = newPassword
),
requestingUser = SharedTestDataADM.rootUser,
apiRequestID = UUID.randomUUID()
UnsafeZioRun.runOrThrow(
UsersResponder.changePassword(
SharedTestDataADM.normalUser.userIri,
PasswordChangeRequest(requesterPassword, newPassword),
SharedTestDataADM.rootUser,
UUID.randomUUID()
)
)

expectMsgType[UserOperationResponseADM](timeout)

// need to be able to authenticate credentials with new password
val cedId = CredentialsIdentifier.UsernameIdentifier(Username.unsafeFrom(normalUser.username))
val credentials = KnoraCredentialsV2.KnoraPasswordCredentialsV2(cedId, "test654321")
Expand Down Expand Up @@ -385,49 +380,6 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender {
response2.user.permissions.isSystemAdmin should equal(false)
}

"return a 'ForbiddenException' if the user requesting update is not the user itself or system admin" in {
/* Password is updated by other normal user */
appActor ! UserChangePasswordRequestADM(
userIri = SharedTestDataADM.superUser.id,
userUpdatePasswordPayload = UserUpdatePasswordPayloadADM(
requesterPassword = Password.unsafeFrom("test"),
newPassword = Password.unsafeFrom("test123456")
),
requestingUser = SharedTestDataADM.normalUser,
UUID.randomUUID
)
expectMsg(
timeout,
Failure(
ForbiddenException("User's password can only be changed by the user itself or a system administrator")
)
)

/* Status is updated by other normal user */
appActor ! UserChangeStatusRequestADM(
userIri = SharedTestDataADM.superUser.id,
status = UserStatus.from(false),
requestingUser = SharedTestDataADM.normalUser,
UUID.randomUUID
)
expectMsg(
timeout,
Failure(ForbiddenException("User's status can only be changed by the user itself or a system administrator"))
)

/* System admin group membership */
appActor ! UserChangeSystemAdminMembershipStatusRequestADM(
userIri = SharedTestDataADM.normalUser.id,
systemAdmin = SystemAdmin.from(true),
requestingUser = SharedTestDataADM.normalUser,
UUID.randomUUID()
)
expectMsg(
timeout,
Failure(ForbiddenException("User's system admin membership can only be changed by a system administrator"))
)
}

"return 'BadRequest' if system user is requested to change" in {
appActor ! UserChangeStatusRequestADM(
userIri = KnoraSystemInstances.Users.SystemUser.id,
Expand Down
Expand Up @@ -7,7 +7,6 @@ package org.knora.webapi.messages.admin.responder.usersmessages

import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import spray.json.*
import zio.prelude.Validation

import java.util.UUID

Expand All @@ -23,7 +22,6 @@ import org.knora.webapi.messages.admin.responder.permissionsmessages.Permissions
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJsonProtocol
import org.knora.webapi.slice.admin.domain.model.*
import org.knora.webapi.slice.common.ToValidation.validateOneWithFrom

//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// API requests
Expand Down Expand Up @@ -81,10 +79,6 @@ case class ChangeUserApiRequestADM(
def toJsValue: JsValue = UsersADMJsonProtocol.changeUserApiRequestADMFormat.write(this)
}

case class ChangeUserPasswordApiRequestADM(requesterPassword: Option[String], newPassword: Option[String]) {
def toJsValue: JsValue = UsersADMJsonProtocol.changeUserPasswordApiRequestADMFormat.write(this)
}

//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Messages

Expand Down Expand Up @@ -114,21 +108,6 @@ case class UserGetByIriADM(
requestingUser: User
) extends UsersResponderRequestADM

/**
* Request updating the users password.
*
* @param userIri the IRI of the user to be updated.
* @param userUpdatePasswordPayload the [[UserUpdatePasswordPayloadADM]] object containing the old and new password.
* @param requestingUser the user initiating the request.
* @param apiRequestID the ID of the API request.
*/
case class UserChangePasswordRequestADM(
userIri: IRI,
userUpdatePasswordPayload: UserUpdatePasswordPayloadADM,
requestingUser: User,
apiRequestID: UUID
) extends UsersResponderRequestADM

/**
* Request updating the users status ('knora-base:isActiveUser' property)
*
Expand Down Expand Up @@ -408,20 +387,6 @@ case class UserChangeRequestADM(
}
}

case class UserUpdatePasswordPayloadADM(requesterPassword: Password, newPassword: Password)
object UserUpdatePasswordPayloadADM {
def make(apiRequest: ChangeUserPasswordApiRequestADM): Validation[String, UserUpdatePasswordPayloadADM] = {
val requesterPasswordValidation = apiRequest.requesterPassword
.map(validateOneWithFrom(_, Password.from, a => a))
.getOrElse(Validation.fail("The requester's password is missing."))
val newPasswordValidation =
apiRequest.newPassword
.map(validateOneWithFrom(_, Password.from, a => a))
.getOrElse(Validation.fail("The new password is missing."))
Validation.validateWith(requesterPasswordValidation, newPasswordValidation)(UserUpdatePasswordPayloadADM.apply)
}
}

/**
* Represents an answer to a group membership request.
*
Expand Down Expand Up @@ -449,11 +414,6 @@ object UsersADMJsonProtocol
jsonFormat(GroupMembersGetResponseADM, "members")
implicit val changeUserApiRequestADMFormat: RootJsonFormat[ChangeUserApiRequestADM] =
jsonFormat(ChangeUserApiRequestADM, "username", "email", "givenName", "familyName", "lang", "status", "systemAdmin")
implicit val changeUserPasswordApiRequestADMFormat: RootJsonFormat[ChangeUserPasswordApiRequestADM] = jsonFormat(
ChangeUserPasswordApiRequestADM,
"requesterPassword",
"newPassword"
)
implicit val usersGetResponseADMFormat: RootJsonFormat[UsersGetResponseADM] = jsonFormat1(UsersGetResponseADM)
implicit val userProfileResponseADMFormat: RootJsonFormat[UserResponseADM] = jsonFormat1(UserResponseADM)
implicit val userProjectMembershipsGetResponseADMFormat: RootJsonFormat[UserProjectMembershipsGetResponseADM] =
Expand Down
Expand Up @@ -7,6 +7,7 @@ package org.knora.webapi.responders.admin

import com.typesafe.scalalogging.LazyLogging
import zio.IO
import zio.RIO
import zio.Task
import zio.URLayer
import zio.ZIO
Expand Down Expand Up @@ -42,6 +43,7 @@ import org.knora.webapi.responders.IriService
import org.knora.webapi.responders.Responder
import org.knora.webapi.slice.admin.AdminConstants
import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest
import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest
import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest
import org.knora.webapi.slice.admin.domain.model.*
import org.knora.webapi.slice.admin.domain.service.PasswordService
Expand Down Expand Up @@ -81,13 +83,6 @@ final case class UsersResponder(
case UsersGetRequestADM(requestingUser) => getAllUserADMRequest(requestingUser)
case UserGetByIriADM(identifier, userInformationTypeADM, requestingUser) =>
findUserByIri(identifier, userInformationTypeADM, requestingUser)
case UserChangePasswordRequestADM(
userIri,
userUpdatePasswordPayload,
requestingUser,
apiRequestID
) =>
changePasswordADM(userIri, userUpdatePasswordPayload, requestingUser, apiRequestID)
case UserChangeStatusRequestADM(userIri, status, requestingUser, apiRequestID) =>
changeUserStatusADM(userIri, status, requestingUser, apiRequestID)
case UserChangeSystemAdminMembershipStatusRequestADM(
Expand Down Expand Up @@ -305,7 +300,7 @@ final case class UsersResponder(
* Change the users password. The old password needs to be supplied for security purposes.
*
* @param userIri the IRI of the existing user that we want to update.
* @param userUpdatePasswordPayload the current password of the requesting user and the new password.
* @param changeRequest the current password of the requesting user and the new password.
*
* @param requestingUser the requesting user.
* @param apiRequestID the unique api request ID.
Expand All @@ -315,53 +310,32 @@ final case class UsersResponder(
* fails with a [[ForbiddenException]] if the supplied old password doesn't match with the user's current password.
* fails with a [[NotFoundException]] if the user is not found.
*/
private def changePasswordADM(
userIri: IRI,
userUpdatePasswordPayload: UserUpdatePasswordPayloadADM,
def changePassword(
userIri: UserIri,
changeRequest: PasswordChangeRequest,
requestingUser: User,
apiRequestID: UUID
): Task[UserOperationResponseADM] = {

/**
* The actual change password task run with an IRI lock.
*/
def changePasswordTask(
userIri: IRI,
update: UserUpdatePasswordPayloadADM,
requestingUser: User
): Task[UserOperationResponseADM] =
val updateTask =
for {
// check if the requesting user is allowed to perform updates (i.e. requesting updates own information or is system admin)
_ <- ZIO.attempt(
if (!requestingUser.id.equalsIgnoreCase(userIri) && !requestingUser.permissions.isSystemAdmin) {
throw ForbiddenException(
"User's password can only be changed by the user itself or a system administrator"
)
}
)

// check if supplied password matches requesting user's password
_ <- ZIO
.fromOption(requestingUser.password)
.map(PasswordHash.unsafeFrom)
.mapBoth(
_ => ForbiddenException("The requesting user has no password."),
pwHash => passwordService.matches(update.requesterPassword, pwHash)
)
.filterOrFail(identity)(
ForbiddenException("The supplied password does not match the requesting user's password.")
)

newPasswordHash = passwordService.hashPassword(update.newPassword)
result <- updateUserPasswordADM(userIri, newPasswordHash, Users.SystemUser)
_ <- // check if supplied password matches requesting user's password
ZIO
.fromOption(requestingUser.password)
.map(PasswordHash.unsafeFrom)
.mapBoth(
_ => ForbiddenException("The requesting user has no password."),
pwHash => passwordService.matches(changeRequest.requesterPassword, pwHash)
)
.filterOrFail(identity)(
ForbiddenException("The supplied password does not match the requesting user's password.")
)

newPasswordHash = passwordService.hashPassword(changeRequest.newPassword)
result <- updateUserPasswordADM(userIri.value, newPasswordHash, Users.SystemUser)

} yield result

IriLocker.runWithIriLock(
apiRequestID,
userIri,
changePasswordTask(userIri, userUpdatePasswordPayload, requestingUser)
)
IriLocker.runWithIriLock(apiRequestID, userIri.value, updateTask)
}

/**
Expand Down Expand Up @@ -1523,6 +1497,14 @@ object UsersResponder {
): ZIO[UsersResponder, Throwable, UserOperationResponseADM] =
ZIO.serviceWithZIO[UsersResponder](_.changeBasicUserInformationADM(userIri, changeRequest, apiRequestID))

def changePassword(
userIri: UserIri,
changeRequest: PasswordChangeRequest,
requestingUser: User,
apiRequestID: UUID
): RIO[UsersResponder, UserOperationResponseADM] =
ZIO.serviceWithZIO[UsersResponder](_.changePassword(userIri, changeRequest, requestingUser, apiRequestID))

val layer: URLayer[
AuthorizationRestService & AppConfig & IriConverter & IriService & PasswordService & MessageRelay & UserService & StringFormatter & TriplestoreService,
UsersResponder
Expand Down
Expand Up @@ -17,7 +17,6 @@ import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.admin.responder.usersmessages.UsersADMJsonProtocol.*
import org.knora.webapi.messages.admin.responder.usersmessages.*
import org.knora.webapi.routing.Authenticator
import org.knora.webapi.routing.RouteUtilADM
import org.knora.webapi.routing.RouteUtilADM.getIriUserUuid
import org.knora.webapi.routing.RouteUtilADM.getUserUuid
import org.knora.webapi.routing.RouteUtilADM.runJsonRouteZ
Expand All @@ -34,8 +33,7 @@ final case class UsersRouteADM()(
private val usersBasePath: PathMatcher[Unit] = PathMatcher("admin" / "users")

def makeRoute: Route =
changeUserPassword() ~
changeUserStatus() ~
changeUserStatus() ~
changeUserSystemAdminMembership() ~
addUserToProjectMembership() ~
removeUserFromProjectMembership() ~
Expand All @@ -44,23 +42,6 @@ final case class UsersRouteADM()(
addUserToGroupMembership() ~
removeUserFromGroupMembership()

/**
* Change user's password.
*/
private def changeUserPassword(): Route =
path(usersBasePath / "iri" / Segment / "Password") { userIri =>
put {
entity(as[ChangeUserPasswordApiRequestADM]) { apiRequest => requestContext =>
val task = for {
checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri)
payload <- UserUpdatePasswordPayloadADM.make(apiRequest).mapError(BadRequestException(_)).toZIO
r <- getUserUuid(requestContext)
} yield UserChangePasswordRequestADM(checkedUserIri, payload, r.user, r.uuid)
RouteUtilADM.runJsonRouteZ(task, requestContext)
}
}
}

/**
* Change user's status.
*/
Expand Down

0 comments on commit 8167d42

Please sign in to comment.