Implementation web context of PAC4J for Lagom framework.
+ *Context is immutable and the {@link SessionStore} is not supported.
+ * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +class LagomWebContext(requestHeader: RequestHeader) extends WebContext { + + override def getSessionStore: SessionStore[_ <: WebContext] = throw new TechnicalException("Operation not supported") + + override def getRequestParameter(s: String): String = throw new TechnicalException("Operation not supported") + + override def getRequestParameters: util.Map[String, Array[String]] = throw new TechnicalException("Operation not supported") + + override def getRequestAttribute(s: String): AnyRef = throw new TechnicalException("Operation not supported") + + override def setRequestAttribute(s: String, o: Any): Unit = throw new TechnicalException("Operation not supported") + + override def getRequestHeader(name: String): String = requestHeader.getHeader(name) match { + case Some(value) => value + case None => throw new TechnicalException(s"Header $name not found") + } + + override def getRequestMethod: String = requestHeader.method.name + + override def getRemoteAddr: String = throw new TechnicalException("Operation not supported") + + override def writeResponseContent(s: String): Unit = throw new TechnicalException("Operation not supported") + + override def setResponseStatus(i: Int): Unit = throw new TechnicalException("Operation not supported") + + override def setResponseHeader(s: String, s1: String): Unit = throw new TechnicalException("Operation not supported") + + override def setResponseContentType(s: String): Unit = throw new TechnicalException("Operation not supported") + + override def getServerName: String = throw new TechnicalException("Operation not supported") + + override def getServerPort: Int = throw new TechnicalException("Operation not supported") + + override def getScheme: String = throw new TechnicalException("Operation not supported") + + override def isSecure: Boolean = false + + override def getFullRequestURL: String = throw new TechnicalException("Operation not supported") + + override def getRequestCookies: util.Collection[Cookie] = throw new TechnicalException("Operation not supported") + + override def addResponseCookie(cookie: Cookie): Unit = throw new TechnicalException("Operation not supported") + + override def getPath: String = requestHeader.uri.getPath + +} diff --git a/shared/src/main/scala/org/pac4j/lagom/scaladsl/SecuredService.scala b/shared/src/main/scala/org/pac4j/lagom/scaladsl/SecuredService.scala new file mode 100644 index 0000000..708cdc4 --- /dev/null +++ b/shared/src/main/scala/org/pac4j/lagom/scaladsl/SecuredService.scala @@ -0,0 +1,137 @@ +package org.pac4j.lagom.scaladsl + +import java.util.Collections.singletonList + +import com.lightbend.lagom.scaladsl.api.transport.Forbidden +import com.lightbend.lagom.scaladsl.server.ServerServiceCall +import org.pac4j.core.authorization.authorizer.Authorizer +import org.pac4j.core.client.Client +import org.pac4j.core.config.Config +import org.pac4j.core.credentials.Credentials +import org.pac4j.core.profile.{AnonymousProfile, CommonProfile} + +/** + *+ * Interface, that implement cross-cutting security concerns for Lagom services. + *
+ *+ * More information about service call composition in Lagom documentation. + *
+ * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +trait SecuredService { + + /** + * Get configuration of pac4j for this service. + * + * @return pac4j configuration + */ + def securityConfig: Config + + /** + * Service call composition for authentication. + * + * @param serviceCall Service call + * @tparam Request Type of request + * @tparam Response Type of response + * @return Service call with authentication logic + */ + def authenticate[Request, Response]( + serviceCall: CommonProfile => ServerServiceCall[Request, Response]): ServerServiceCall[Request, Response] = + authenticate(securityConfig.getClients.getDefaultSecurityClients, serviceCall) + + /** + * Service call composition for authentication. + * + * @param clientName Name of authentication client + * @param serviceCall Service call + * @tparam Request Type of request + * @tparam Response Type of response + * @return Service call with authentication logic + */ + def authenticate[Request, Response]( + clientName: String, serviceCall: CommonProfile => ServerServiceCall[Request, Response]): ServerServiceCall[Request, Response] = + ServerServiceCall.compose { requestHeader => + val profile = try { + val clients = securityConfig.getClients + val defaultClient = clients.findClient(clientName).asInstanceOf[Client[Credentials, CommonProfile]] + val context = new LagomWebContext(requestHeader) + val credentials = defaultClient.getCredentials(context) + defaultClient.getUserProfile(credentials, context) + } catch { + case ex: Exception => + // We can throw only TransportException. + // Otherwise exception will be sent to the client with stack trace. + new AnonymousProfile + } + + serviceCall.apply(Option(profile).getOrElse(new AnonymousProfile)) + } + + /** + * Service call composition for authorization. + * + * @param authorizer Authorizer (may be composite) + * @param serviceCall Service call + * @tparam Request Type of request + * @tparam Response Type of response + * @return Service call with authorization logic + */ + def authorize[Request, Response]( + authorizer: Authorizer[CommonProfile], serviceCall: CommonProfile => ServerServiceCall[Request, Response]): ServerServiceCall[Request, Response] = + authorize(securityConfig.getClients.getDefaultSecurityClients, authorizer, serviceCall) + + /** + * Service call composition for authorization. + * + * @param clientName Name of authentication client + * @param authorizer Authorizer (may be composite) + * @param serviceCall Service call + * @tparam Request Type of request + * @tparam Response Type of response + * @return Service call with authorization logic + */ + def authorize[Request, Response]( + clientName: String, authorizer: Authorizer[CommonProfile], serviceCall: CommonProfile => ServerServiceCall[Request, Response]): ServerServiceCall[Request, Response] = + authenticate(clientName, (profile: CommonProfile) => ServerServiceCall.compose { requestHeader => + val authorized = try { + authorizer != null && authorizer.isAuthorized(new LagomWebContext(requestHeader), singletonList(profile)) + } catch { + case ex: Exception => + // We can throw only TransportException. + // Otherwise exception will be sent to the client with stack trace. + false + } + if (!authorized) throw Forbidden("Authorization failed") + serviceCall.apply(profile) + }) + + /** + * Service call composition for authorization. + * + * @param authorizerName Name of authorizer, registered in security config + * @param serviceCall Service call + * @tparam Request Type of request + * @tparam Response Type of response + * @return Service call with authorization logic + */ + def authorize[Request, Response]( + authorizerName: String, serviceCall: CommonProfile => ServerServiceCall[Request, Response]): ServerServiceCall[Request, Response] = + authorize(securityConfig.getAuthorizers.get(authorizerName).asInstanceOf[Authorizer[CommonProfile]], serviceCall) + + /** + * Service call composition for authorization. + * + * @param clientName Name of authentication client + * @param authorizerName Name of authorizer, registered in security config + * @param serviceCall Service call + * @tparam Request Type of request + * @tparam Response Type of response + * @return Service call with authorization logic + */ + def authorize[Request, Response]( + clientName: String, authorizerName: String, serviceCall: CommonProfile => ServerServiceCall[Request, Response]): ServerServiceCall[Request, Response] = + authorize(clientName, securityConfig.getAuthorizers.get(authorizerName).asInstanceOf[Authorizer[CommonProfile]], serviceCall) +} diff --git a/shared/src/test/scala/org/pac4j/lagom/scaladsl/ClientNames.scala b/shared/src/test/scala/org/pac4j/lagom/scaladsl/ClientNames.scala new file mode 100644 index 0000000..02084e6 --- /dev/null +++ b/shared/src/test/scala/org/pac4j/lagom/scaladsl/ClientNames.scala @@ -0,0 +1,14 @@ +package org.pac4j.lagom.scaladsl + +/** + * Names of clients, that will be used for tests. + * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +object ClientNames { + + val HEADER_CLIENT = "simple_header" + val HEADER_JWT_CLIENT = "jwt_header" + +} diff --git a/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestModule.scala b/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestModule.scala new file mode 100644 index 0000000..ee17cf8 --- /dev/null +++ b/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestModule.scala @@ -0,0 +1,42 @@ +package org.pac4j.lagom.scaladsl + +import org.pac4j.core.config.Config +import org.pac4j.core.context.HttpConstants.AUTHORIZATION_HEADER +import org.pac4j.core.context.WebContext +import org.pac4j.core.credentials.authenticator.Authenticator +import org.pac4j.core.credentials.{Credentials, TokenCredentials} +import org.pac4j.core.profile.CommonProfile +import org.pac4j.http.client.direct.HeaderClient + +import org.pac4j.core.authorization.authorizer.IsAnonymousAuthorizer.isAnonymous +import org.pac4j.core.authorization.authorizer.IsAuthenticatedAuthorizer.isAuthenticated + +/** + * DI module for run tests. + * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +trait TestModule { + + lazy val client: HeaderClient = { + val headerClient = new HeaderClient(AUTHORIZATION_HEADER, new Authenticator[Credentials]() { + override def validate(credentials: Credentials, webContext: WebContext): Unit = { + val profile = new CommonProfile() + profile.setId(credentials.asInstanceOf[TokenCredentials].getToken) + credentials.setUserProfile(profile) + } + }) + headerClient.setName(ClientNames.HEADER_CLIENT) + headerClient + } + + lazy val serviceConfig: Config = { + val config = new Config(client) + config.getClients.setDefaultSecurityClients(client.getName) + config.addAuthorizer("_anonymous_", isAnonymous()) + config.addAuthorizer("_authenticated_", isAuthenticated()) + config + } + +} diff --git a/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestService.scala b/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestService.scala new file mode 100644 index 0000000..4037398 --- /dev/null +++ b/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestService.scala @@ -0,0 +1,37 @@ +package org.pac4j.lagom.scaladsl + +import akka.NotUsed +import com.lightbend.lagom.scaladsl.api.{Descriptor, Service, ServiceCall} + +import com.lightbend.lagom.scaladsl.api.Service.{named, pathCall} + +/** + * Descriptor of Lagom service for tests. + * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +trait TestService extends Service { + + def defaultAuthenticate: ServiceCall[NotUsed, String] + + def defaultAuthorize: ServiceCall[NotUsed, String] + + def defaultAuthorizeConfig: ServiceCall[NotUsed, String] + + def headerAuthenticate: ServiceCall[NotUsed, String] + + def headerAuthorize: ServiceCall[NotUsed, String] + + def headerAuthorizeConfig: ServiceCall[NotUsed, String] + + override def descriptor: Descriptor = named("default").withCalls( + pathCall("/default/authenticate", this.defaultAuthenticate), + pathCall("/default/authorize", this.defaultAuthorize), + pathCall("/default/authorize/config", this.defaultAuthorizeConfig), + pathCall("/header/authenticate", this.headerAuthenticate), + pathCall("/header/authorize", this.headerAuthorize), + pathCall("/header/authorize/config", this.headerAuthorizeConfig) + ).withAutoAcl(true) + +} diff --git a/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestServiceImpl.scala b/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestServiceImpl.scala new file mode 100644 index 0000000..981509c --- /dev/null +++ b/shared/src/test/scala/org/pac4j/lagom/scaladsl/TestServiceImpl.scala @@ -0,0 +1,50 @@ +package org.pac4j.lagom.scaladsl +import akka.NotUsed +import com.lightbend.lagom.scaladsl.api.ServiceCall +import com.lightbend.lagom.scaladsl.server.ServerServiceCall +import org.pac4j.core.authorization.authorizer.Authorizer +import org.pac4j.core.config.Config +import org.pac4j.core.profile.CommonProfile +import org.pac4j.lagom.scaladsl.ClientNames.HEADER_CLIENT + +import scala.concurrent.Future + +import org.pac4j.core.authorization.authorizer.IsAuthenticatedAuthorizer.isAuthenticated + +/** + * Implementation of Lagom service for tests. + * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +class TestServiceImpl(override val securityConfig: Config) extends TestService with SecuredService { + + override def defaultAuthenticate: ServiceCall[NotUsed, String] = { + authenticate((profile: CommonProfile) => ServerServiceCall { request: NotUsed => Future.successful(profile.getId) }) + } + + override def defaultAuthorize: ServiceCall[NotUsed, String] = { + val authorizer: Authorizer[CommonProfile] = isAuthenticated() + authorize(authorizer, (profile: CommonProfile) => ServerServiceCall { request: NotUsed => Future.successful(profile.getId) }) + } + + override def defaultAuthorizeConfig: ServiceCall[NotUsed, String] = { + val authorizerName = "_authenticated_" + authorize(authorizerName, (profile: CommonProfile) => ServerServiceCall { request: NotUsed => Future.successful(profile.getId) }) + } + + override def headerAuthenticate: ServiceCall[NotUsed, String] = { + authenticate(HEADER_CLIENT, (profile: CommonProfile) => ServerServiceCall { request: NotUsed => Future.successful(profile.getId) }) + } + + override def headerAuthorize: ServiceCall[NotUsed, String] = { + val authorizer: Authorizer[CommonProfile] = isAuthenticated() + authorize(HEADER_CLIENT, authorizer, (profile: CommonProfile) => ServerServiceCall { request: NotUsed => Future.successful(profile.getId) }) + } + + override def headerAuthorizeConfig: ServiceCall[NotUsed, String] = { + val authorizerName = "_authenticated_" + authorize(HEADER_CLIENT, authorizerName, (profile: CommonProfile) => ServerServiceCall { request: NotUsed => Future.successful(profile.getId) }) + } + +} diff --git a/shared/src/test/scala/org/pac4j/lagom/scaladsl/test/DefaultClientTest.scala b/shared/src/test/scala/org/pac4j/lagom/scaladsl/test/DefaultClientTest.scala new file mode 100644 index 0000000..112888e --- /dev/null +++ b/shared/src/test/scala/org/pac4j/lagom/scaladsl/test/DefaultClientTest.scala @@ -0,0 +1,83 @@ +package org.pac4j.lagom.scaladsl.test + +import com.lightbend.lagom.scaladsl.api.transport.{Forbidden, RequestHeader} +import com.lightbend.lagom.scaladsl.server.{LagomApplication, LagomServer, LocalServiceLocator} +import com.lightbend.lagom.scaladsl.testkit.ServiceTest +import org.pac4j.core.context.HttpConstants.AUTHORIZATION_HEADER +import org.pac4j.lagom.scaladsl.{TestModule, TestService, TestServiceImpl} +import org.scalatest.{AsyncWordSpec, BeforeAndAfterAll, Matchers} +import play.api.libs.ws.ahc.AhcWSComponents +import com.lightbend.lagom.scaladsl.testkit.ServiceTest.TestServer +import com.softwaremill.macwire.wire + +/** + * Test of security logic for default client ({@link org.pac4j.http.client.direct.HeaderClient}). + * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +class DefaultClientTest extends AsyncWordSpec with Matchers with BeforeAndAfterAll { + + lazy val server: TestServer[LagomApplication with LocalServiceLocator with AhcWSComponents] = { + ServiceTest.startServer(ServiceTest.defaultSetup.withCluster(false)) { ctx => + new LagomApplication(ctx) with LocalServiceLocator with AhcWSComponents with TestModule { + override def lagomServer: LagomServer = LagomServer.forService( + bindService[TestService].to(wire[TestServiceImpl]) + ) + } + } + } + + lazy val service: TestService = server.serviceClient.implement[TestService] + + "TestService" should { + + "authenticate by anonymous" in { + service.defaultAuthenticate.invoke.map { result => + result should ===("anonymous") + } + } + + "authenticate by Alice" in { + service.defaultAuthenticate.handleRequestHeader((header: RequestHeader) => header.withHeader(AUTHORIZATION_HEADER, "Alice")).invoke.map { result => + result should ===("Alice") + } + } + + "not authorize by anonymous" in { + service.defaultAuthorize.invoke.map { result => + fail("authorize by anonymous should be forbidden") + } recoverWith { + case f: Forbidden => + f.getMessage should ===("Authorization failed") + } + } + + "authorize by Alice" in { + service.defaultAuthorize.handleRequestHeader((header: RequestHeader) => header.withHeader(AUTHORIZATION_HEADER, "Alice")).invoke.map { result => + result should ===("Alice") + } + } + + "not authorize by anonymous (authorizer from config)" in { + service.defaultAuthorizeConfig.invoke.map { result => + fail("authorize by anonymous should be forbidden") + } recoverWith { + case f: Forbidden => + f.getMessage should ===("Authorization failed") + } + } + + "authorize by Alice (authorizer from config)" in { + service.defaultAuthorizeConfig.handleRequestHeader((header: RequestHeader) => header.withHeader(AUTHORIZATION_HEADER, "Alice")).invoke.map { result => + result should ===("Alice") + } + } + + } + + override protected def beforeAll(): Unit = server + + override protected def afterAll(): Unit = server.stop() + +} diff --git a/shared/src/test/scala/org/pac4j/lagom/scaladsl/test/HeaderClientTest.scala b/shared/src/test/scala/org/pac4j/lagom/scaladsl/test/HeaderClientTest.scala new file mode 100644 index 0000000..c68edaa --- /dev/null +++ b/shared/src/test/scala/org/pac4j/lagom/scaladsl/test/HeaderClientTest.scala @@ -0,0 +1,83 @@ +package org.pac4j.lagom.scaladsl.test + +import com.lightbend.lagom.scaladsl.api.transport.{Forbidden, RequestHeader} +import com.lightbend.lagom.scaladsl.server.{LagomApplication, LagomServer, LocalServiceLocator} +import com.lightbend.lagom.scaladsl.testkit.ServiceTest +import com.lightbend.lagom.scaladsl.testkit.ServiceTest.TestServer +import com.softwaremill.macwire.wire +import org.pac4j.core.context.HttpConstants.AUTHORIZATION_HEADER +import org.pac4j.lagom.scaladsl.{TestModule, TestService, TestServiceImpl} +import org.scalatest.{AsyncWordSpec, BeforeAndAfterAll, Matchers} +import play.api.libs.ws.ahc.AhcWSComponents + +/** + * Test of security logic for simple {@link org.pac4j.http.client.direct.HeaderClient}. + * + * @author Vladimir Kornyshev + * @since 1.0.0 + */ +class HeaderClientTest extends AsyncWordSpec with Matchers with BeforeAndAfterAll { + + lazy val server: TestServer[LagomApplication with LocalServiceLocator with AhcWSComponents] = { + ServiceTest.startServer(ServiceTest.defaultSetup.withCluster(false)) { ctx => + new LagomApplication(ctx) with LocalServiceLocator with AhcWSComponents with TestModule { + override def lagomServer: LagomServer = LagomServer.forService( + bindService[TestService].to(wire[TestServiceImpl]) + ) + } + } + } + + lazy val service: TestService = server.serviceClient.implement[TestService] + + "TestService" should { + + "authenticate by anonymous" in { + service.headerAuthenticate.invoke.map { result => + result should ===("anonymous") + } + } + + "authenticate by Alice" in { + service.headerAuthenticate.handleRequestHeader((header: RequestHeader) => header.withHeader(AUTHORIZATION_HEADER, "Alice")).invoke.map { result => + result should ===("Alice") + } + } + + "not authorize by anonymous" in { + service.headerAuthorize.invoke.map { result => + fail("authorize by anonymous should be forbidden") + } recoverWith { + case f: Forbidden => + f.getMessage should ===("Authorization failed") + } + } + + "authorize by Alice" in { + service.headerAuthorize.handleRequestHeader((header: RequestHeader) => header.withHeader(AUTHORIZATION_HEADER, "Alice")).invoke.map { result => + result should ===("Alice") + } + } + + "not authorize by anonymous (authorizer from config)" in { + service.headerAuthorizeConfig.invoke.map { result => + fail("authorize by anonymous should be forbidden") + } recoverWith { + case f: Forbidden => + f.getMessage should ===("Authorization failed") + } + } + + "authorize by Alice (authorizer from config)" in { + service.headerAuthorizeConfig.handleRequestHeader((header: RequestHeader) => header.withHeader(AUTHORIZATION_HEADER, "Alice")).invoke.map { result => + result should ===("Alice") + } + } + + } + + override protected def beforeAll(): Unit = server + + override protected def afterAll(): Unit = server.stop() + +}