Skip to content

Commit

Permalink
refactor: Migrate secure admin/projects endpoints to Tapir (#2872)
Browse files Browse the repository at this point in the history
  • Loading branch information
seakayone authored Oct 12, 2023
1 parent 08accab commit 9f98f7e
Show file tree
Hide file tree
Showing 33 changed files with 945 additions and 1,032 deletions.
49 changes: 14 additions & 35 deletions docs/03-endpoints/api-v2/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,27 @@

# Authentication

Access to the DSP-API can for certain operations require a user to authenticate.
Authentication can be performed in two ways:
Certain routes are secured and require authentication.
When accessing any secured route we support three options for authentication:

1. By providing *password credentials*, which are a combination of a *identifier* and
*password*. The user *identifier* can be one of the following:
- the user's IRI,
- the user's Email, or
- the user's Username.
- **Preferred method**: For each request an [Access Token](#Access-Token-/-Login-and-Logout) is sent in the HTTP
authorization
header with the
[HTTP bearer scheme](https://tools.ietf.org/html/rfc6750#section-2.1).
- **Deprecated method**: For each request an [Access Token](#Access-Token-/-Login-and-Logout) is provided as a cookie in
the HTTP request.
- **Deprecated method**: [HTTP basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication), where
the username is the user's `email`.

2. By providing an *access token*
## Access Token / Login and Logout

## Submitting Password Credentials

When accessing any route and password credentials would need to be sent,
we support two options to do so:

- in the URL submitting the parameters `iri` / `email` / `username` and `password`
(e.g., <http://knora-host/v1/resources/resIri?email=userUrlEncodedIdentifier&password=pw>), and
- in the HTTP header ([HTTP basic
authentication](https://en.wikipedia.org/wiki/Basic_access_authentication)), where the
identifier can be the user's `email` (IRI and username not supported).

When using Python's module `requests`, the credentials can simply be submitted as a tuple with
each request using the param `auth` ([python requests](http://docs.python-requests.org/en/master/user/authentication/#basic-authentication)).

## Access Token / Session / Login and Logout

A client can generate an *access token* by sending a POST request (e.g., `{"identifier_type":"identifier_value",
A client can obtain an *access token* by sending a POST request (e.g., `{"identifier_type":"identifier_value",
"password":"password_value"}`) to the **/v2/authentication** route with
*identifier* and *password* in the body. The `identifier_type` can be `iri`, `email`, or `username`.
If the credentials are valid, a [JSON WEB Token](https://jwt.io) (JWT) will be sent back in the
response (e.g., `{"token": "eyJ0eXAiOiJ..."}`). Additionally, for web browser clients a session cookie
containing the JWT token is also created, containing `KnoraAuthentication=eyJ0eXAiOiJ...`.

When accessing any route, the *access token* would need to be supplied, we support three options to do so:

- the session cookie,
- in the URL submitting the parameter `token` (e.g., <http://knora-host/v1/resources/resIri?token=1234567890>), and
- in the HTTP authorization header with the [HTTP bearer scheme](https://tools.ietf.org/html/rfc6750#section-2.1).

If the token is successfully validated, then the user is deemed authenticated.

To **logout**, the client sends a DELETE request to the same route **/v2/authentication** and
the *access token* in one of the three described ways. This will invalidate the access token,
thus not allowing further request that would supply the invalidated token.
Expand All @@ -58,5 +37,5 @@ supplied as URL parameters or HTTP authentication headers as described before.

## Usage Scenarios

1. Create token by logging-in, send token on each subsequent request, and logout when finished.
2. Send email/password credentials on every request.
1. Create token by logging-in, send token on each subsequent request, and logout when finished.
2. Send email/password credentials on every request.
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import org.knora.webapi.responders.v2.ontology.OntologyHelpers
import org.knora.webapi.responders.v2.ontology.OntologyHelpersLive
import org.knora.webapi.routing._
import org.knora.webapi.routing.admin.AuthenticatorService
import org.knora.webapi.routing.admin.ProjectsEndpoints
import org.knora.webapi.routing.admin.ProjectsEndpointsHandlerF
import org.knora.webapi.routing.admin.ProjectsRouteZ
import org.knora.webapi.slice.admin.api.ProjectsEndpoints
import org.knora.webapi.slice.admin.api.ProjectsEndpointsHandler
import org.knora.webapi.slice.admin.api.service.ProjectADMRestService
import org.knora.webapi.slice.admin.api.service.ProjectsADMRestServiceLive
import org.knora.webapi.slice.admin.domain.service._
import org.knora.webapi.slice.admin.repo.service.KnoraProjectRepoLive
import org.knora.webapi.slice.common.api.BaseEndpoints
import org.knora.webapi.slice.common.api.HandlerMapperF
import org.knora.webapi.slice.common.api.RestPermissionService
import org.knora.webapi.slice.common.api.RestPermissionServiceLive
import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper
Expand Down Expand Up @@ -174,7 +176,7 @@ object LayersTest {
ProjectImportServiceLive.layer,
ProjectsADMRestServiceLive.layer,
ProjectsEndpoints.layer,
ProjectsEndpointsHandlerF.layer,
ProjectsEndpointsHandler.layer,
ProjectsResponderADMLive.layer,
ProjectsRouteZ.layer,
QueryTraverser.layer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ class ProjectsADME2EZioHttpSpec extends E2ESpec with ProjectsADMJsonProtocol {
}
}

if (baseApiUrl.contains("5555")) "used to set RestrictedViewSize by project IRI" should {
"used to set RestrictedViewSize by project IRI" should {
"return requested value to be set with 200 Response Status" in {
val encodedIri = URLEncoder.encode(SharedTestDataADM.imagesProject.id, "utf-8")
val payload = """{"size":"pct:1"}"""
Expand Down Expand Up @@ -823,9 +823,8 @@ class ProjectsADME2EZioHttpSpec extends E2ESpec with ProjectsADMJsonProtocol {
assert(response.status === StatusCodes.Forbidden)
}
}
else "used to set RestrictedViewSize by project IRI" ignore ()

if (baseApiUrl.contains("5555")) "used to set RestrictedViewSize by project Shortcode" should {
"used to set RestrictedViewSize by project Shortcode" should {
"return requested value to be set with 200 Response Status" in {
val shortcode = SharedTestDataADM.imagesProject.shortcode
val payload = """{"size":"pct:1"}"""
Expand Down Expand Up @@ -872,6 +871,5 @@ class ProjectsADME2EZioHttpSpec extends E2ESpec with ProjectsADMJsonProtocol {
assert(response.status === StatusCodes.Forbidden)
}
}
else "used to set RestrictedViewSize by project Shortcode" ignore ()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,13 @@ import dsp.errors.BadRequestException
import dsp.errors.OntologyConstraintException
import dsp.valueobjects.V2
import org.knora.webapi._
import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM._
import org.knora.webapi.sharedtestdata.SharedTestDataADM

/**
* This spec is used to test subclasses of the [[ProjectsResponderRequestADM]] trait.
*/
class ProjectsMessagesADMSpec extends CoreSpec {
private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance

"The ChangeProjectApiRequestADM case class" should {
"return a 'BadRequest' when everything is 'None" in {
assertThrows[BadRequestException](
ChangeProjectApiRequestADM(
shortname = None,
longname = None,
description = None,
keywords = None,
logo = None,
status = None,
selfjoin = None
)
)
}
}

"The ProjectADM case class" should {
"return a 'OntologyConstraintException' when project description is not supplied" in {
assertThrows[OntologyConstraintException](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentif
import org.knora.webapi.messages.admin.responder.projectsmessages._
import org.knora.webapi.messages.admin.responder.usersmessages.UserInformationTypeADM
import org.knora.webapi.sharedtestdata.SharedTestDataADM
import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequests.ProjectCreateRequest
import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequests.ProjectUpdateRequest
import org.knora.webapi.util.MutableTestIri

import pekko.actor.Status.Failure
Expand Down Expand Up @@ -164,7 +166,7 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {
"CREATE a project and return the project info if the supplied shortname is unique" in {
val shortcode = "111c"
appActor ! ProjectCreateRequestADM(
createRequest = ProjectCreatePayloadADM(
createRequest = ProjectCreateRequest(
shortname = Shortname.make("newproject").fold(error => throw error.head, value => value),
shortcode = Shortcode.make(shortcode).fold(error => throw error.head, value => value), // lower case
longname = Name.make(Some("project longname")).fold(error => throw error.head, value => value),
Expand Down Expand Up @@ -259,7 +261,7 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {

"CREATE a project and return the project info if the supplied shortname and shortcode is unique" in {
appActor ! ProjectCreateRequestADM(
createRequest = ProjectCreatePayloadADM(
createRequest = ProjectCreateRequest(
shortname = Shortname.make("newproject2").fold(error => throw error.head, value => value),
shortcode = Shortcode.make("1112").fold(error => throw error.head, value => value), // lower case
longname = Some(Name.make("project longname").fold(error => throw error.head, value => value)),
Expand Down Expand Up @@ -291,7 +293,7 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {
val descriptionWithSpecialCharacter = "project \\\"description\\\""
val keywordWithSpecialCharacter = "new \\\"keyword\\\""
appActor ! ProjectCreateRequestADM(
createRequest = ProjectCreatePayloadADM(
createRequest = ProjectCreateRequest(
shortname = Shortname.make("project_with_char").fold(error => throw error.head, value => value),
shortcode = Shortcode.make("1312").fold(error => throw error.head, value => value), // lower case
longname = Name.make(Some(longnameWithSpecialCharacter)).fold(error => throw error.head, value => value),
Expand Down Expand Up @@ -323,7 +325,7 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {

"return a 'DuplicateValueException' during creation if the supplied project shortname is not unique" in {
appActor ! ProjectCreateRequestADM(
createRequest = ProjectCreatePayloadADM(
createRequest = ProjectCreateRequest(
shortname = Shortname.make("newproject").fold(error => throw error.head, value => value),
shortcode = Shortcode.make("111C").fold(error => throw error.head, value => value), // lower case
longname = Name.make(Some("project longname")).fold(error => throw error.head, value => value),
Expand All @@ -343,7 +345,7 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {

"return a 'DuplicateValueException' during creation if the supplied project shortname is unique but the shortcode is not" in {
appActor ! ProjectCreateRequestADM(
createRequest = ProjectCreatePayloadADM(
createRequest = ProjectCreateRequest(
shortname = Shortname.make("newproject3").fold(error => throw error.head, value => value),
shortcode = Shortcode.make("111C").fold(error => throw error.head, value => value), // lower case
longname = Name.make(Some("project longname")).fold(error => throw error.head, value => value),
Expand Down Expand Up @@ -374,7 +376,7 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {

appActor ! ProjectChangeRequestADM(
projectIri = iri,
projectUpdatePayload = ProjectUpdatePayloadADM(
projectUpdatePayload = ProjectUpdateRequest(
shortname = None,
longname = Some(updatedLongname),
description = Some(updatedDescription),
Expand Down Expand Up @@ -409,7 +411,7 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {
val iri = ITTestDataFactory.projectIri(notExistingProjectButValidProjectIri)
appActor ! ProjectChangeRequestADM(
projectIri = iri,
projectUpdatePayload = ProjectUpdatePayloadADM(longname = Some(longname)),
projectUpdatePayload = ProjectUpdateRequest(longname = Some(longname)),
SharedTestDataADM.rootUser,
UUID.randomUUID()
)
Expand All @@ -421,12 +423,6 @@ class ProjectsResponderADMSpec extends CoreSpec with ImplicitSender {
)
)
}

"return 'BadRequest' if nothing would be changed during the update" in {

an[BadRequestException] should be thrownBy ChangeProjectApiRequestADM(None, None, None, None, None, None, None)

}
}

"used to query members" should {
Expand Down
18 changes: 17 additions & 1 deletion webapi/src/main/scala/dsp/errors/Errors.scala
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,21 @@ object BadRequestException {
*/
case class BadCredentialsException(message: String) extends RequestRejectedException(message)

object BadCredentialsException {
implicit val codec: JsonCodec[BadCredentialsException] = DeriveJsonCodec.gen[BadCredentialsException]
}

/**
* An exception indicating that a user has made a request for which the user lacks the necessary permission.
*
* @param message a description of the error.
*/
case class ForbiddenException(message: String) extends RequestRejectedException(message)

object ForbiddenException {
implicit val codec: JsonCodec[ForbiddenException] = DeriveJsonCodec.gen[ForbiddenException]
}

/**
* An exception indicating that the requested data was not found.
*
Expand All @@ -130,6 +138,10 @@ object NotFoundException {
case class DuplicateValueException(message: String = "Duplicate values are not permitted")
extends RequestRejectedException(message)

object DuplicateValueException {
implicit val codec: JsonCodec[DuplicateValueException] = DeriveJsonCodec.gen[DuplicateValueException]
}

/**
* An exception indicating that a requested update is not allowed because it would violate an ontology constraint,
* e.g. an `knora-base:objectClassConstraint` or an OWL cardinality restriction.
Expand Down Expand Up @@ -189,7 +201,11 @@ case class InvalidRdfException(msg: String, cause: Throwable = null) extends Req
* @param msg a description of the error.
* @param cause the cause for the error
*/
case class ValidationException(msg: String, cause: Throwable = null) extends RequestRejectedException(msg, cause)
case class ValidationException(msg: String) extends RequestRejectedException(msg)

object ValidationException {
implicit val codec: JsonCodec[ValidationException] = DeriveJsonCodec.gen[ValidationException]
}

/**
* An abstract class for exceptions indicating that something went wrong and it's not the client's fault.
Expand Down
4 changes: 4 additions & 0 deletions webapi/src/main/scala/dsp/valueobjects/Project.scala
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ object Project {
*/
sealed abstract case class ProjectStatus private (value: Boolean)
object ProjectStatus { self =>

val deleted = new ProjectStatus(false) {}
val active = new ProjectStatus(true) {}

implicit val decoder: JsonDecoder[ProjectStatus] = JsonDecoder[Boolean].mapOrFail { case value =>
ProjectStatus.make(value).toEitherWith(e => e.head.getMessage())
}
Expand Down
8 changes: 5 additions & 3 deletions webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import org.knora.webapi.responders.v2.ontology.OntologyHelpers
import org.knora.webapi.responders.v2.ontology.OntologyHelpersLive
import org.knora.webapi.routing._
import org.knora.webapi.routing.admin.AuthenticatorService
import org.knora.webapi.routing.admin.ProjectsEndpoints
import org.knora.webapi.routing.admin.ProjectsEndpointsHandlerF
import org.knora.webapi.routing.admin.ProjectsRouteZ
import org.knora.webapi.slice.admin.api.ProjectsEndpoints
import org.knora.webapi.slice.admin.api.ProjectsEndpointsHandler
import org.knora.webapi.slice.admin.api.service.ProjectADMRestService
import org.knora.webapi.slice.admin.api.service.ProjectsADMRestServiceLive
import org.knora.webapi.slice.admin.domain.service._
import org.knora.webapi.slice.admin.repo.service.KnoraProjectRepoLive
import org.knora.webapi.slice.common.api.BaseEndpoints
import org.knora.webapi.slice.common.api.HandlerMapperF
import org.knora.webapi.slice.common.api.RestPermissionService
import org.knora.webapi.slice.common.api.RestPermissionServiceLive
import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper
Expand Down Expand Up @@ -176,7 +178,7 @@ object LayersLive {
ProjectImportServiceLive.layer,
ProjectsADMRestServiceLive.layer,
ProjectsEndpoints.layer,
ProjectsEndpointsHandlerF.layer,
ProjectsEndpointsHandler.layer,
ProjectsResponderADMLive.layer,
ProjectsRouteZ.layer,
QueryTraverser.layer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ object ApiStatusCodesV2 {
case OntologyConstraintException(_) => StatusCodes.BadRequest
case EditConflictException(_) => StatusCodes.Conflict
case BadRequestException(_) => StatusCodes.BadRequest
case ValidationException(_, _) => StatusCodes.BadRequest
case ValidationException(_) => StatusCodes.BadRequest
case RequestRejectedException(_) => StatusCodes.BadRequest
// RequestRejectedException must be the last one in this group

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ object ApiStatusCodesZ {
case OntologyConstraintException(_) => Status.BadRequest
case EditConflictException(_) => Status.Conflict
case BadRequestException(_) => Status.BadRequest
case ValidationException(_, _) => Status.BadRequest
case ValidationException(_) => Status.BadRequest
case RequestRejectedException(_) => Status.BadRequest
// RequestRejectedException must be the last one in this group

Expand Down
Loading

0 comments on commit 9f98f7e

Please sign in to comment.