diff --git a/apps/dashboard-app/src/main/scala/net/shrine/dashboard/jwtauth/ShrineJwtAuthenticator.scala b/apps/dashboard-app/src/main/scala/net/shrine/dashboard/jwtauth/ShrineJwtAuthenticator.scala index 377cf68c6..ee8249574 100644 --- a/apps/dashboard-app/src/main/scala/net/shrine/dashboard/jwtauth/ShrineJwtAuthenticator.scala +++ b/apps/dashboard-app/src/main/scala/net/shrine/dashboard/jwtauth/ShrineJwtAuthenticator.scala @@ -1,195 +1,199 @@ package net.shrine.dashboard.jwtauth import java.io.ByteArrayInputStream import java.security.{Principal, Key, PrivateKey} import java.security.cert.{CertificateFactory, X509Certificate} import java.util.Date import io.jsonwebtoken.impl.TextCodec import io.jsonwebtoken.{Jws, ClaimJwtException, Claims, SignatureAlgorithm, Jwts} import net.shrine.crypto.{KeyStoreDescriptorParser, KeyStoreCertCollection} import net.shrine.dashboard.DashboardConfigSource import net.shrine.i2b2.protocol.pm.User import net.shrine.log.Loggable import net.shrine.protocol.Credential import spray.http.HttpHeaders.{Authorization, `WWW-Authenticate`} import spray.http.{HttpRequest, OAuth2BearerToken, HttpHeader, HttpChallenge} import spray.routing.AuthenticationFailedRejection.{CredentialsMissing, CredentialsRejected} import spray.routing.AuthenticationFailedRejection import spray.routing.authentication._ import scala.concurrent.{Future, ExecutionContext} import scala.util.{Success, Failure, Try} /** * An Authenticator that uses Jwt in a Bearer header to authenticate. See http://jwt.io/introduction/ for what this is all about, * https://tools.ietf.org/html/rfc7519 for what it might include for claims. * * @author david * @since 12/21/15 */ object ShrineJwtAuthenticator extends Loggable { val config = DashboardConfigSource.config val certCollection: KeyStoreCertCollection = KeyStoreCertCollection.fromFileRecoverWithClassPath(KeyStoreDescriptorParser(config.getConfig("shrine.keystore"))) //from https://groups.google.com/forum/#!topic/spray-user/5DBEZUXbjtw def authenticate(implicit ec: ExecutionContext): ContextAuthenticator[User] = { ctx => Future { val attempt: Try[Authentication[User]] = for { header:HttpHeader <- extractAuthorizationHeader(ctx.request) jwtsString:String <- extractJwtsStringAndCheckScheme(header) jwtsClaims <- extractJwtsClaims(jwtsString) cert: X509Certificate <- extractAndCheckCert(jwtsClaims) jwtsBody:Claims <- Try{jwtsClaims.getBody} jwtsSubject <- failIfNull(jwtsBody.getSubject,MissingRequiredJwtsClaim("subject",cert.getSubjectDN)) jwtsIssuer <- failIfNull(jwtsBody.getSubject,MissingRequiredJwtsClaim("issuer",cert.getSubjectDN)) } yield { val user = User( fullName = cert.getSubjectDN.getName, username = jwtsSubject, domain = jwtsIssuer, credential = Credential(jwtsIssuer, isToken = false), params = Map(), rolesByProject = Map() ) Right(user) } //todo use a fold() in Scala 2.12 attempt match { case Success(rightUser) => rightUser case Failure(x) => x match { case anticipated: ShrineJwtException => info(s"Failed to authenticate due to ${anticipated.toString}",anticipated) anticipated.rejection case fromJwts: ClaimJwtException => info(s"Failed to authenticate due to ${fromJwts.toString} while authenticating ${ctx.request}",fromJwts) rejectedCredentials /* case x: CertificateExpiredException => { //todo will these even be thrown here? Get some identification here info(s"Cert expired.", x) rejectedCredentials } case x: CertificateNotYetValidException => { info(s"Cert not yet valid.", x) rejectedCredentials } */ case unanticipated => warn(s"Unanticipated ${unanticipated.toString} while authenticating ${ctx.request}",unanticipated) rejectedCredentials } } } } def createOAuthCredentials(user:User): OAuth2BearerToken = { val base64Cert = new String(TextCodec.BASE64URL.encode(certCollection.myCert.get.getEncoded)) val key: PrivateKey = certCollection.myKeyPair.privateKey val expiration: Date = new Date(System.currentTimeMillis() + 30 * 1000) //good for 30 seconds val jwtsString = Jwts.builder(). setHeaderParam("kid", base64Cert). setSubject(s"${user.username} at ${user.domain}"). setIssuer(java.net.InetAddress.getLocalHost.getHostName). //todo is it OK for me to use issuer this way or should I use my own claim? setExpiration(expiration). signWith(SignatureAlgorithm.RS512, key). compact() OAuth2BearerToken(jwtsString) } def extractAuthorizationHeader(request: HttpRequest):Try[HttpHeader] = Try { case class NoAuthorizationHeaderException(request: HttpRequest) extends ShrineJwtException(s"No ${Authorization.name} header found in $request",missingCredentials) //noinspection ComparingUnrelatedTypes request.headers.find(_.name.equals(Authorization.name)).getOrElse{throw NoAuthorizationHeaderException(request)} } def extractJwtsStringAndCheckScheme(httpHeader: HttpHeader) = Try { val splitHeaderValue: Array[String] = httpHeader.value.trim.split(" ") if (splitHeaderValue.length != 2) { case class WrongNumberOfSegmentsException(httpHeader: HttpHeader) extends ShrineJwtException(s"Header had ${splitHeaderValue.length} space-delimited segments, not 2, in $httpHeader.",missingCredentials) throw new WrongNumberOfSegmentsException(httpHeader) } else if(splitHeaderValue(0) != BearerAuthScheme) { case class NotBearerAuthException(httpHeader: HttpHeader) extends ShrineJwtException(s"Expected $BearerAuthScheme, not ${splitHeaderValue(0)} in $httpHeader.",missingCredentials) throw new NotBearerAuthException(httpHeader) } else splitHeaderValue(1) } def extractJwtsClaims(jwtsString:String): Try[Jws[Claims]] = Try { Jwts.parser().setSigningKeyResolver(new SigningKeyResolverBridge()).parseClaimsJws(jwtsString) } def extractAndCheckCert(jwtsClaims:Jws[Claims]): Try[X509Certificate] = Try { val cert = KeySource.certForString(jwtsClaims.getHeader.getKeyId) val issuingSite = jwtsClaims.getBody.getIssuer - //check that the issuer is available - val issuer: Principal = cert.getIssuerX500Principal - case class CertIssuerNotInCollectionException(issuingSite:String,issuer: Principal) extends ShrineJwtException(s"Could not find a CA certificate with issuer DN $issuer. Known CA cert aliases are ${certCollection.caCertAliases.mkString(",")}") - val signingCert = certCollection.caCerts.getOrElse(issuer,{throw CertIssuerNotInCollectionException(issuingSite,issuer)}) - - //verify that the cert was signed using the signingCert - //todo this can throw CertificateException, NoSuchAlgorithmException, InvalidKeyException, NoSuchProviderException, SignatureException - cert.verify(signingCert.getPublicKey) - - //todo has cert expired? - - cert + certCollection.caCerts.get(cert.getSubjectX500Principal).fold{ + //if not, check that the issuer is available + val issuer: Principal = cert.getIssuerX500Principal + case class CertIssuerNotInCollectionException(issuingSite:String,issuer: Principal) extends ShrineJwtException(s"Could not find a CA certificate with issuer DN $issuer. Known CA cert aliases are ${certCollection.caCertAliases.mkString(",")}") + val signingCert = certCollection.caCerts.getOrElse(issuer,{throw CertIssuerNotInCollectionException(issuingSite,issuer)}) + + //verify that the cert was signed using the signingCert + //todo this can throw CertificateException, NoSuchAlgorithmException, InvalidKeyException, NoSuchProviderException, SignatureException + cert.verify(signingCert.getPublicKey) + //todo has cert expired? + info(s"${cert.getSubjectX500Principal} verified using $issuer from the KeyStore") + cert + }{ principal => //if the cert is in the certCollection then all is well + info(s"$principal is in the KeyStore") + cert + } } def failIfNull[E](e:E,t:Throwable):Try[E] = Try { if(e == null) throw t else e } case class MissingRequiredJwtsClaim(field:String,principal: Principal) extends ShrineJwtException(s"$field is null from ${principal.getName}") val BearerAuthScheme = "Bearer" val challengeHeader: `WWW-Authenticate` = `WWW-Authenticate`(HttpChallenge(BearerAuthScheme, "dashboard-to-dashboard")) val missingCredentials: Authentication[User] = Left(AuthenticationFailedRejection(CredentialsMissing, List(challengeHeader))) val rejectedCredentials: Authentication[User] = Left(AuthenticationFailedRejection(CredentialsRejected, List(challengeHeader))) } class KeySource {} object KeySource extends Loggable { def keyForString(string: String): Key = { val certificate =certForString(string) //todo validate cert with something like obtainAndValidateSigningCert //check date on cert vs time. throws CertificateExpiredException or CertificateNotYetValidException for problems //todo skip this until you rebuild the certs used for testing certificate.checkValidity(now) certificate.getPublicKey } def certForString(string: String): X509Certificate = { val certBytes = TextCodec.BASE64URL.decode(string) val inputStream = new ByteArrayInputStream(certBytes) val certificate = try { CertificateFactory.getInstance("X.509").generateCertificate(inputStream).asInstanceOf[X509Certificate] } finally { inputStream.close() } certificate } } abstract class ShrineJwtException(message:String, val rejection:Authentication[User] = ShrineJwtAuthenticator.rejectedCredentials, cause:Throwable = null) extends RuntimeException(message,cause) \ No newline at end of file diff --git a/qep/service/src/main/scala/net/shrine/qep/AbstractQepService.scala b/qep/service/src/main/scala/net/shrine/qep/AbstractQepService.scala index 4a2666411..622d6bcb9 100644 --- a/qep/service/src/main/scala/net/shrine/qep/AbstractQepService.scala +++ b/qep/service/src/main/scala/net/shrine/qep/AbstractQepService.scala @@ -1,209 +1,206 @@ package net.shrine.qep import net.shrine.log.Loggable import net.shrine.qep.audit.QepAuditDb import net.shrine.qep.dao.AuditDao import net.shrine.authentication.Authenticator import net.shrine.authorization.QueryAuthorizationService import net.shrine.broadcaster.BroadcastAndAggregationService import net.shrine.qep.queries.QepQueryDb import scala.concurrent.duration.Duration import net.shrine.util.XmlDateHelper import scala.concurrent.Future import scala.concurrent.Await import net.shrine.protocol.{ReadPreviousQueriesResponse, RunQueryRequest, BaseShrineRequest, AuthenticationInfo, Credential, BaseShrineResponse, ReadQueryInstancesRequest, QueryInstance, ReadQueryInstancesResponse, ReadQueryDefinitionRequest, DeleteQueryRequest, ReadApprovedQueryTopicsRequest, ReadInstanceResultsRequest, ReadPreviousQueriesRequest, RenameQueryRequest, ReadPdoRequest, FlagQueryRequest, UnFlagQueryRequest, ReadResultOutputTypesRequest, ReadResultOutputTypesResponse, ResultOutputType} import net.shrine.authorization.AuthorizationResult.{Authorized, NotAuthorized} import net.shrine.authentication.AuthenticationResult import net.shrine.authentication.NotAuthenticatedException import net.shrine.aggregation.RunQueryAggregator import net.shrine.aggregation.Aggregators import net.shrine.aggregation.Aggregator import net.shrine.aggregation.ReadQueryDefinitionAggregator import net.shrine.aggregation.DeleteQueryAggregator import net.shrine.aggregation.ReadPdoResponseAggregator import net.shrine.aggregation.RenameQueryAggregator import net.shrine.aggregation.ReadInstanceResultsAggregator import net.shrine.aggregation.FlagQueryAggregator import net.shrine.aggregation.UnFlagQueryAggregator /** * @author clint * @since Feb 19, 2014 */ trait AbstractQepService[BaseResp <: BaseShrineResponse] extends Loggable { val commonName:String val auditDao: AuditDao val authenticator: Authenticator val authorizationService: QueryAuthorizationService val includeAggregateResult: Boolean val broadcastAndAggregationService: BroadcastAndAggregationService val queryTimeout: Duration val breakdownTypes: Set[ResultOutputType] val collectQepAudit:Boolean protected def doReadResultOutputTypes(request: ReadResultOutputTypesRequest): BaseResp = { info(s"doReadResultOutputTypes($request)") authenticateAndThen(request) { authResult => val resultOutputTypes = ResultOutputType.nonErrorTypes ++ breakdownTypes //TODO: XXX: HACK: Would like to remove the cast ReadResultOutputTypesResponse(resultOutputTypes).asInstanceOf[BaseResp] } } protected def doFlagQuery(request: FlagQueryRequest, shouldBroadcast: Boolean = true): BaseResp = { doBroadcastQuery(request, new FlagQueryAggregator, shouldBroadcast) } protected def doUnFlagQuery(request: UnFlagQueryRequest, shouldBroadcast: Boolean = true): BaseResp = { doBroadcastQuery(request, new UnFlagQueryAggregator, shouldBroadcast) } protected def doRunQuery(request: RunQueryRequest, shouldBroadcast: Boolean): BaseResp = { info(s"doRunQuery($request,$shouldBroadcast) with $runQueryAggregatorFor") //store the query in the qep's database doBroadcastQuery(request, runQueryAggregatorFor(request), shouldBroadcast) } protected def doReadQueryDefinition(request: ReadQueryDefinitionRequest, shouldBroadcast: Boolean): BaseResp = { doBroadcastQuery(request, new ReadQueryDefinitionAggregator, shouldBroadcast) } protected def doReadPdo(request: ReadPdoRequest, shouldBroadcast: Boolean): BaseResp = { doBroadcastQuery(request, new ReadPdoResponseAggregator, shouldBroadcast) } protected def doReadInstanceResults(request: ReadInstanceResultsRequest, shouldBroadcast: Boolean): BaseResp = { doBroadcastQuery(request, new ReadInstanceResultsAggregator(request.shrineNetworkQueryId, false), shouldBroadcast) } protected def doReadQueryInstances(request: ReadQueryInstancesRequest, shouldBroadcast: Boolean): BaseResp = { info(s"doReadQueryInstances($request)") authenticateAndThen(request) { authResult => val now = XmlDateHelper.now val networkQueryId = request.queryId val username = request.authn.username val groupId = request.projectId //NB: Return a dummy response, with a dummy QueryInstance containing the network (Shrine) id of the query we'd like //to get "instances" for. This allows the legacy web client to formulate a request for query results that Shrine //can understand, while meeting the conversational requirements of the legacy web client. val instance = QueryInstance(networkQueryId.toString, networkQueryId.toString, username, groupId, now, now) //TODO: XXX: HACK: Would like to remove the cast //NB: Munge in username from authentication result ReadQueryInstancesResponse(networkQueryId, authResult.username, groupId, Seq(instance)).asInstanceOf[BaseResp] } } protected def doReadPreviousQueries(request: ReadPreviousQueriesRequest, shouldBroadcast: Boolean): ReadPreviousQueriesResponse = { //pull results from the local database. - - //todo delete at end of local/local effort - // doBroadcastQuery(request, new ReadPreviousQueriesAggregator, shouldBroadcast).asInstanceOf[ReadPreviousQueriesResponse] QepQueryDb.db.selectPreviousQueries(request) } protected def doRenameQuery(request: RenameQueryRequest, shouldBroadcast: Boolean): BaseResp = { doBroadcastQuery(request, new RenameQueryAggregator, shouldBroadcast) } protected def doDeleteQuery(request: DeleteQueryRequest, shouldBroadcast: Boolean): BaseResp = { doBroadcastQuery(request, new DeleteQueryAggregator, shouldBroadcast) } protected def doReadApprovedQueryTopics(request: ReadApprovedQueryTopicsRequest, shouldBroadcast: Boolean): BaseResp = authenticateAndThen(request) { _ => info(s"doReadApprovedQueryTopics($request)") //TODO: XXX: HACK: Would like to remove the cast authorizationService.readApprovedEntries(request) match { case Left(errorResponse) => errorResponse.asInstanceOf[BaseResp] case Right(validResponse) => validResponse.asInstanceOf[BaseResp] } } import broadcastAndAggregationService.sendAndAggregate protected def doBroadcastQuery(request: BaseShrineRequest, aggregator: Aggregator, shouldBroadcast: Boolean): BaseResp = { authenticateAndThen(request) { authResult => debug(s"doBroadcastQuery($request) authResult is $authResult") //NB: Use credentials obtained from Authenticator (oddly, we authenticate with one set of credentials and are "logged in" under (possibly!) another //When making BroadcastMessages val networkAuthn = AuthenticationInfo(authResult.domain, authResult.username, Credential("", isToken = false)) //NB: Only audit RunQueryRequests request match { case runQueryRequest: RunQueryRequest => // inject modified, authorized runQueryRequest auditAuthorizeAndThen(runQueryRequest) { authorizedRequest => debug(s"doBroadcastQuery authorizedRequest is $authorizedRequest") // tuck the ACT audit metrics data into a database here if (collectQepAudit) QepAuditDb.db.insertQepQuery(authorizedRequest,commonName) QepQueryDb.db.insertQepQuery(authorizedRequest) doSynchronousQuery(networkAuthn,authorizedRequest,aggregator,shouldBroadcast) } case _ => doSynchronousQuery(networkAuthn,request,aggregator,shouldBroadcast) } } } private def doSynchronousQuery(networkAuthn: AuthenticationInfo,request: BaseShrineRequest, aggregator: Aggregator, shouldBroadcast: Boolean) = { info(s"doSynchronousQuery($request) started") val response = waitFor(sendAndAggregate(networkAuthn, request, aggregator, shouldBroadcast)).asInstanceOf[BaseResp] info(s"doSynchronousQuery($request) completed with response $response") response } private[qep] val runQueryAggregatorFor: RunQueryRequest => RunQueryAggregator = Aggregators.forRunQueryRequest(includeAggregateResult) protected def waitFor[R](futureResponse: Future[R]): R = { XmlDateHelper.time("Waiting for aggregated results")(debug(_)) { Await.result(futureResponse, queryTimeout) } } private[qep] def auditAuthorizeAndThen[T](request: RunQueryRequest)(body: (RunQueryRequest => T)): T = { auditTransactionally(request) { debug(s"auditAuthorizeAndThen($request) with $authorizationService") val authorizedRequest = authorizationService.authorizeRunQueryRequest(request) match { case na: NotAuthorized => throw na.toException case authorized: Authorized => request.copy(topicName = authorized.topicIdAndName.map(x => x._2)) } body(authorizedRequest) } } private[qep] def auditTransactionally[T](request: RunQueryRequest)(body: => T): T = { try { body } finally { auditDao.addAuditEntry( request.projectId, request.authn.domain, request.authn.username, request.queryDefinition.toI2b2String, //TODO: Use i2b2 format Still? request.topicId) } } import AuthenticationResult._ private[qep] def authenticateAndThen[T](request: BaseShrineRequest)(f: Authenticated => T): T = { val AuthenticationInfo(domain, username, _) = request.authn val authResult = authenticator.authenticate(request.authn) authResult match { case a: Authenticated => f(a) case NotAuthenticated(_, _, reason) => throw new NotAuthenticatedException(s"User $domain:$username could not be authenticated: $reason") } } } \ No newline at end of file