diff --git a/.gitignore b/.gitignore index 2aacb66..ab4a823 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ local.properties # .idea/modules.xml # .idea/*.iml # .idea/modules +*.iml # CMake cmake-build-*/ diff --git a/findbugs-exclude.xml b/findbugs-exclude.xml index 0f44cf2..e1a765e 100644 --- a/findbugs-exclude.xml +++ b/findbugs-exclude.xml @@ -1,2 +1,6 @@ - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/lagom-pac4j_2.11/pom.xml b/lagom-pac4j_2.11/pom.xml index 1cf577f..36cbaef 100644 --- a/lagom-pac4j_2.11/pom.xml +++ b/lagom-pac4j_2.11/pom.xml @@ -25,6 +25,12 @@ ${lagom.version} provided + + com.lightbend.lagom + lagom-scaladsl-server_${scala.binary.version} + ${lagom.version} + provided + com.lightbend.lagom @@ -32,12 +38,30 @@ ${lagom.version} test + + com.lightbend.lagom + lagom-scaladsl-testkit_${scala.binary.version} + ${lagom.version} + test + com.lightbend.lagom lagom-logback_${scala.binary.version} ${lagom.version} test + + org.scalatest + scalatest_${scala.binary.version} + ${scalatest.version} + test + + + com.softwaremill.macwire + macros_${scala.binary.version} + ${macwire.version} + test + @@ -88,6 +112,20 @@ + + org.scalatest + scalatest-maven-plugin + 2.0.0 + + + test + test + + test + + + + diff --git a/lagom-pac4j_2.12/pom.xml b/lagom-pac4j_2.12/pom.xml index 68b6ba0..575f0f4 100644 --- a/lagom-pac4j_2.12/pom.xml +++ b/lagom-pac4j_2.12/pom.xml @@ -25,6 +25,12 @@ ${lagom.version} provided + + com.lightbend.lagom + lagom-scaladsl-server_${scala.binary.version} + ${lagom.version} + provided + com.lightbend.lagom @@ -32,12 +38,30 @@ ${lagom.version} test + + com.lightbend.lagom + lagom-scaladsl-testkit_${scala.binary.version} + ${lagom.version} + test + com.lightbend.lagom lagom-logback_${scala.binary.version} ${lagom.version} test + + org.scalatest + scalatest_${scala.binary.version} + ${scalatest.version} + test + + + com.softwaremill.macwire + macros_${scala.binary.version} + ${macwire.version} + test + @@ -88,6 +112,20 @@ + + org.scalatest + scalatest-maven-plugin + 2.0.0 + + + test + test + + test + + + + diff --git a/pom.xml b/pom.xml index ca530b4..300e294 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,8 @@ 5.3.1 3.11.1 2.9.4 + 3.0.1 + 2.3.1 @@ -153,6 +155,7 @@ compile + testCompile diff --git a/shared/src/main/scala/org/pac4j/lagom/scaladsl/LagomWebContext.scala b/shared/src/main/scala/org/pac4j/lagom/scaladsl/LagomWebContext.scala new file mode 100644 index 0000000..7845be8 --- /dev/null +++ b/shared/src/main/scala/org/pac4j/lagom/scaladsl/LagomWebContext.scala @@ -0,0 +1,62 @@ +package org.pac4j.lagom.scaladsl + +import java.util + +import com.lightbend.lagom.scaladsl.api.transport.RequestHeader +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.context.{Cookie, WebContext} +import org.pac4j.core.exception.TechnicalException + +/** + *

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() + +}