From 680021e8ffd477b162e36a05eeeb4a1c6389d127 Mon Sep 17 00:00:00 2001 From: Ivan Subotic <400790+subotic@users.noreply.github.com> Date: Thu, 7 Jul 2022 23:06:37 +0200 Subject: [PATCH] fix(authentication): make cookie name unique between environments (#2091) --- sipi/scripts/basexx.lua | 298 ++++++++++++++++++ sipi/scripts/get_knora_session.lua | 28 +- webapi/src/main/resources/application.conf | 2 - .../org/knora/webapi/config/AppConfig.scala | 1 - .../knora/webapi/routing/Authenticator.scala | 223 ++++++------- .../knora/webapi/settings/KnoraSettings.scala | 2 - ...ADME2ESpec.scala => FilesADME2ESpec.scala} | 14 +- .../e2e/v1/AuthenticationV1E2ESpec.scala | 45 +-- .../webapi/routing/AuthenticatorSpec.scala | 6 + 9 files changed, 445 insertions(+), 174 deletions(-) create mode 100644 sipi/scripts/basexx.lua rename webapi/src/test/scala/org/knora/webapi/e2e/admin/{SipiADME2ESpec.scala => FilesADME2ESpec.scala} (93%) diff --git a/sipi/scripts/basexx.lua b/sipi/scripts/basexx.lua new file mode 100644 index 0000000000..e6d617a82c --- /dev/null +++ b/sipi/scripts/basexx.lua @@ -0,0 +1,298 @@ +-------------------------------------------------------------------------------- +-- https://github.com/aiq/basexx/releases/tag/v0.4.1 +-- util functions +-------------------------------------------------------------------------------- + +local function divide_string( str, max ) + local result = {} + + local start = 1 + for i = 1, #str do + if i % max == 0 then + table.insert( result, str:sub( start, i ) ) + start = i + 1 + elseif i == #str then + table.insert( result, str:sub( start, i ) ) + end + end + + return result +end + +local function number_to_bit( num, length ) + local bits = {} + + while num > 0 do + local rest = math.floor( math.fmod( num, 2 ) ) + table.insert( bits, rest ) + num = ( num - rest ) / 2 + end + + while #bits < length do + table.insert( bits, "0" ) + end + + return string.reverse( table.concat( bits ) ) +end + +local function ignore_set( str, set ) + if set then + str = str:gsub( "["..set.."]", "" ) + end + return str +end + +local function pure_from_bit( str ) + return ( str:gsub( '........', function ( cc ) + return string.char( tonumber( cc, 2 ) ) + end ) ) +end + +local function unexpected_char_error( str, pos ) + local c = string.sub( str, pos, pos ) + return string.format( "unexpected character at position %d: '%s'", pos, c ) +end + +-------------------------------------------------------------------------------- + +local basexx = {} + +-------------------------------------------------------------------------------- +-- base2(bitfield) decode and encode function +-------------------------------------------------------------------------------- + +local bitMap = { o = "0", i = "1", l = "1" } + +function basexx.from_bit( str, ignore ) + str = ignore_set( str, ignore ) + str = string.lower( str ) + str = str:gsub( '[ilo]', function( c ) return bitMap[ c ] end ) + local pos = string.find( str, "[^01]" ) + if pos then return nil, unexpected_char_error( str, pos ) end + + return pure_from_bit( str ) +end + +function basexx.to_bit( str ) + return ( str:gsub( '.', function ( c ) + local byte = string.byte( c ) + local bits = {} + for _ = 1,8 do + table.insert( bits, byte % 2 ) + byte = math.floor( byte / 2 ) + end + return table.concat( bits ):reverse() + end ) ) +end + +-------------------------------------------------------------------------------- +-- base16(hex) decode and encode function +-------------------------------------------------------------------------------- + +function basexx.from_hex( str, ignore ) + str = ignore_set( str, ignore ) + local pos = string.find( str, "[^%x]" ) + if pos then return nil, unexpected_char_error( str, pos ) end + + return ( str:gsub( '..', function ( cc ) + return string.char( tonumber( cc, 16 ) ) + end ) ) +end + +function basexx.to_hex( str ) + return ( str:gsub( '.', function ( c ) + return string.format('%02X', string.byte( c ) ) + end ) ) +end + +-------------------------------------------------------------------------------- +-- generic function to decode and encode base32/base64 +-------------------------------------------------------------------------------- + +local function from_basexx( str, alphabet, bits ) + local result = {} + for i = 1, #str do + local c = string.sub( str, i, i ) + if c ~= '=' then + local index = string.find( alphabet, c, 1, true ) + if not index then + return nil, unexpected_char_error( str, i ) + end + table.insert( result, number_to_bit( index - 1, bits ) ) + end + end + + local value = table.concat( result ) + local pad = #value % 8 + return pure_from_bit( string.sub( value, 1, #value - pad ) ) +end + +local function to_basexx( str, alphabet, bits, pad ) + local bitString = basexx.to_bit( str ) + + local chunks = divide_string( bitString, bits ) + local result = {} + for _,value in ipairs( chunks ) do + if ( #value < bits ) then + value = value .. string.rep( '0', bits - #value ) + end + local pos = tonumber( value, 2 ) + 1 + table.insert( result, alphabet:sub( pos, pos ) ) + end + + table.insert( result, pad ) + return table.concat( result ) +end + +-------------------------------------------------------------------------------- +-- rfc 3548: http://www.rfc-editor.org/rfc/rfc3548.txt +-------------------------------------------------------------------------------- + +local base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" +local base32PadMap = { "", "======", "====", "===", "=" } + +function basexx.from_base32( str, ignore ) + str = ignore_set( str, ignore ) + return from_basexx( string.upper( str ), base32Alphabet, 5 ) +end + +function basexx.to_base32( str ) + return to_basexx( str, base32Alphabet, 5, base32PadMap[ #str % 5 + 1 ] ) +end + +-------------------------------------------------------------------------------- +-- crockford: http://www.crockford.com/wrmg/base32.html +-------------------------------------------------------------------------------- + +local crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +local crockfordMap = { O = "0", I = "1", L = "1" } + +function basexx.from_crockford( str, ignore ) + str = ignore_set( str, ignore ) + str = string.upper( str ) + str = str:gsub( '[ILOU]', function( c ) return crockfordMap[ c ] end ) + return from_basexx( str, crockfordAlphabet, 5 ) +end + +function basexx.to_crockford( str ) + return to_basexx( str, crockfordAlphabet, 5, "" ) +end + +-------------------------------------------------------------------------------- +-- base64 decode and encode function +-------------------------------------------------------------------------------- + +local base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".. + "abcdefghijklmnopqrstuvwxyz".. + "0123456789+/" +local base64PadMap = { "", "==", "=" } + +function basexx.from_base64( str, ignore ) + str = ignore_set( str, ignore ) + return from_basexx( str, base64Alphabet, 6 ) +end + +function basexx.to_base64( str ) + return to_basexx( str, base64Alphabet, 6, base64PadMap[ #str % 3 + 1 ] ) +end + +-------------------------------------------------------------------------------- +-- URL safe base64 decode and encode function +-------------------------------------------------------------------------------- + +local url64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".. + "abcdefghijklmnopqrstuvwxyz".. + "0123456789-_" + +function basexx.from_url64( str, ignore ) + str = ignore_set( str, ignore ) + return from_basexx( str, url64Alphabet, 6 ) +end + +function basexx.to_url64( str ) + return to_basexx( str, url64Alphabet, 6, "" ) +end + +-------------------------------------------------------------------------------- +-- +-------------------------------------------------------------------------------- + +local function length_error( len, d ) + return string.format( "invalid length: %d - must be a multiple of %d", len, d ) +end + +local z85Decoder = { 0x00, 0x44, 0x00, 0x54, 0x53, 0x52, 0x48, 0x00, + 0x4B, 0x4C, 0x46, 0x41, 0x00, 0x3F, 0x3E, 0x45, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x40, 0x00, 0x49, 0x42, 0x4A, 0x47, + 0x51, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, + 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, + 0x3B, 0x3C, 0x3D, 0x4D, 0x00, 0x4E, 0x43, 0x00, + 0x00, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, + 0x21, 0x22, 0x23, 0x4F, 0x00, 0x50, 0x00, 0x00 } + +function basexx.from_z85( str, ignore ) + str = ignore_set( str, ignore ) + if ( #str % 5 ) ~= 0 then + return nil, length_error( #str, 5 ) + end + + local result = {} + + local value = 0 + for i = 1, #str do + local index = string.byte( str, i ) - 31 + if index < 1 or index >= #z85Decoder then + return nil, unexpected_char_error( str, i ) + end + value = ( value * 85 ) + z85Decoder[ index ] + if ( i % 5 ) == 0 then + local divisor = 256 * 256 * 256 + while divisor ~= 0 do + local b = math.floor( value / divisor ) % 256 + table.insert( result, string.char( b ) ) + divisor = math.floor( divisor / 256 ) + end + value = 0 + end + end + + return table.concat( result ) +end + +local z85Encoder = "0123456789".. + "abcdefghijklmnopqrstuvwxyz".. + "ABCDEFGHIJKLMNOPQRSTUVWXYZ".. + ".-:+=^!/*?&<>()[]{}@%$#" + +function basexx.to_z85( str ) + if ( #str % 4 ) ~= 0 then + return nil, length_error( #str, 4 ) + end + + local result = {} + + local value = 0 + for i = 1, #str do + local b = string.byte( str, i ) + value = ( value * 256 ) + b + if ( i % 4 ) == 0 then + local divisor = 85 * 85 * 85 * 85 + while divisor ~= 0 do + local index = ( math.floor( value / divisor ) % 85 ) + 1 + table.insert( result, z85Encoder:sub( index, index ) ) + divisor = math.floor( divisor / 85 ) + end + value = 0 + end + end + + return table.concat( result ) +end + +-------------------------------------------------------------------------------- + +return basexx diff --git a/sipi/scripts/get_knora_session.lua b/sipi/scripts/get_knora_session.lua index 54f661f6df..10b14c5674 100644 --- a/sipi/scripts/get_knora_session.lua +++ b/sipi/scripts/get_knora_session.lua @@ -1,6 +1,8 @@ -- * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. -- * SPDX-License-Identifier: Apache-2.0 +basexx = require( "basexx" ) + ------------------------------------------------------------------------------- -- This function is called from the route to get the Knora session id from the cookie. -- The cookie is sent to Sipi by the client (HTTP request header). @@ -17,13 +19,37 @@ function get_session_id(cookie) return nil end + -- name of the cokie depends on the environment defined as host:port combination + -- this combination is "mangled" using base32 and appended to "KnoraAuthentication" + -- to get the correct cokie, we need to calculate first the mangled host-port combination + local webapi_hostname = os.getenv("KNORA_WEBAPI_KNORA_API_EXTERNAL_HOST") + local webapi_port = os.getenv("KNORA_WEBAPI_KNORA_API_EXTERNAL_PORT") + if webapi_hostname == nil then + send_error(500, "KNORA_WEBAPI_KNORA_API_EXTERNAL_HOST not set") + return nil + end + if webapi_port == nil then + send_error(500, "KNORA_WEBAPI_KNORA_API_EXTERNAL_PORT not set") + return nil + end + + host_port = webapi_hostname .. ':' .. webapi_port + server.log("host_port: " .. host_port, server.loglevel.LOG_DEBUG) + + local customPadMap = { "", "999999", "9999", "999", "9" } + host_port_base32 = basexx.to_basexx(host_port, base32Alphabet, 5, customPadMap) + server.log("host_port_base32: " .. host_port_base32, server.loglevel.LOG_DEBUG) + + + -- tries to extract the Knora session id from the cookie: -- gets the digits between "sid=" and the closing ";" (only given in case of several key value pairs) -- ";" is expected to separate different key value pairs (https://tools.ietf.org/html/rfc6265#section-4.2.1) -- space is also treated as a separator -- returns nil if it cannot find the session id (pattern does not match) server.log("extracted cookie: " .. cookie, server.loglevel.LOG_DEBUG) - local session_id = string.match(cookie, "KnoraAuthentication=([^%s;]+)") + local session_id = string.match(cookie, "KnoraAuthentication" .. host_port_base32 .. "=([^%s;]+)") + server.log("extracted session_id: " .. session_id, server.loglevel.LOG_DEBUG) return session_id diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index 8ae05a850b..45c82debbe 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -283,8 +283,6 @@ app { show-internal-errors = true // If true, clients will see error messages from internal errors. Useful for debugging. If false, those error messages will appear only in the log. - skip-authentication = false // If true, the authentication process is skiped and the Lothar Schmidt user is returned by default. - bcrypt-password-strength = 12 // Value range is 10-32. bcrypt-password-strength = ${?KNORA_WEBAPI_BCRYPT_PASSWORD_STRENGTH} diff --git a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala index e7454b35db..26a521af3a 100644 --- a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala +++ b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala @@ -19,7 +19,6 @@ final case class AppConfig( defaultTimeout: String, dumpMessages: Boolean, showInternalErrors: Boolean, - skipAuthentication: Boolean, bcryptPasswordStrength: Int, jwtSecretKey: String, jwtLongevity: String, diff --git a/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala b/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala index 39572470da..36363d1bd1 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala @@ -48,6 +48,9 @@ import scala.concurrent.duration._ import scala.util.Failure import scala.util.Success import scala.util.Try +import scala.annotation.tailrec +import org.knora.webapi.settings.KnoraSettingsImpl +import org.apache.commons.codec.binary.Base32 /** * This trait is used in routes that need authentication support. It provides methods that use the [[RequestContext]] @@ -80,9 +83,8 @@ trait Authenticator extends InstrumentationSupport { executionContext: ExecutionContext ): Future[HttpResponse] = { - val settings = KnoraSettings(system) - - val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext) + val settings = KnoraSettings(system) + val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext, settings) for { userADM <- getUserADMThroughCredentialsV2( @@ -102,7 +104,7 @@ trait Authenticator extends InstrumentationSupport { headers = List( headers.`Set-Cookie`( HttpCookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + calculateCookieName(settings), sessionToken, domain = cookieDomain, path = Some("/"), @@ -165,7 +167,7 @@ trait Authenticator extends InstrumentationSupport { headers = List( headers.`Set-Cookie`( HttpCookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + calculateCookieName(settings), token, domain = cookieDomain, path = Some("/"), @@ -251,7 +253,8 @@ trait Authenticator extends InstrumentationSupport { executionContext: ExecutionContext ): Future[HttpResponse] = { - val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext) + val settings = KnoraSettings(system) + val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext, settings) for { // will authenticate and either return or throw @@ -288,7 +291,8 @@ trait Authenticator extends InstrumentationSupport { executionContext: ExecutionContext ): Future[HttpResponse] = { - val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext) + val settings = KnoraSettings(system) + val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext, settings) for { // will throw exception if not valid @@ -321,9 +325,8 @@ trait Authenticator extends InstrumentationSupport { */ def doLogoutV2(requestContext: RequestContext)(implicit system: ActorSystem): HttpResponse = { - val credentials = extractCredentialsV2(requestContext) - val settings = KnoraSettings(system) + val credentials = extractCredentialsV2(requestContext, settings) val cookieDomain = Some(settings.cookieDomain) credentials match { @@ -334,7 +337,7 @@ trait Authenticator extends InstrumentationSupport { headers = List( headers.`Set-Cookie`( HttpCookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + calculateCookieName(settings), "", domain = cookieDomain, path = Some("/"), @@ -360,7 +363,7 @@ trait Authenticator extends InstrumentationSupport { headers = List( headers.`Set-Cookie`( HttpCookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + calculateCookieName(settings), "", domain = cookieDomain, path = Some("/"), @@ -397,51 +400,6 @@ trait Authenticator extends InstrumentationSupport { // GET USER PROFILE / AUTHENTICATION ENTRY POINT //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - /** - * Returns a UserProfile of the supplied type that match the credentials found in the [[RequestContext]]. - * The credentials can be email/password as parameters or auth headers, or session token in a cookie header. If no - * credentials are found, then a default UserProfile is returned. If the credentials are not correct, then the - * corresponding error is returned. - * - * @param requestContext a [[RequestContext]] containing the http request - * @param system the current [[ActorSystem]] - * @return a [[UserProfileV1]] - */ - @deprecated("Please use: getUserADM()", "Knora v1.7.0") - def getUserProfileV1(requestContext: RequestContext)(implicit - system: ActorSystem, - appActor: ActorRef, - executionContext: ExecutionContext - ): Future[UserProfileV1] = { - - val settings = KnoraSettings(system) - - val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext) - - if (settings.skipAuthentication) { - // return anonymous if skipAuthentication - log.debug( - "getUserProfileV1 - Authentication skipping active, returning default UserProfileV1 with 'anonymousUser' inside 'permissionData' set to true!" - ) - FastFuture.successful(UserProfileV1()) - } else if (credentials.isEmpty) { - log.debug( - "getUserProfileV1 - No credentials found, returning default UserProfileV1 with 'anonymousUser' inside 'permissionData' set to true!" - ) - FastFuture.successful(UserProfileV1()) - } else { - for { - userADM <- getUserADMThroughCredentialsV2( - credentials = credentials - ) - userProfile: UserProfileV1 = userADM.asUserProfileV1 - _ = log.debug("Authenticator - getUserProfileV1 - userProfile: {}", userProfile) - - /* we return the userProfileV1 without sensitive information */ - } yield userProfile.ofType(UserProfileTypeV1.RESTRICTED) - } - } - /** * Returns a User that match the credentials found in the [[RequestContext]]. * The credentials can be email/password as parameters or auth headers, or session token in a cookie header. If no @@ -459,15 +417,10 @@ trait Authenticator extends InstrumentationSupport { executionContext: ExecutionContext ): Future[UserADM] = { - val settings = KnoraSettings(system) - - val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext) + val settings = KnoraSettings(system) + val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext, settings) - if (settings.skipAuthentication) { - // return anonymous if skipAuthentication - log.debug("getUserADM - Authentication skipping active, returning 'anonymousUser'.") - FastFuture.successful(KnoraSystemInstances.Users.AnonymousUser) - } else if (credentials.isEmpty) { + if (credentials.isEmpty) { log.debug("getUserADM - No credentials found, returning 'anonymousUser'.") FastFuture.successful(KnoraSystemInstances.Users.AnonymousUser) } else { @@ -498,7 +451,6 @@ object Authenticator extends InstrumentationSupport { val BAD_CRED_USER_INACTIVE = "bad credentials: user inactive" val BAD_CRED_NOT_VALID = "bad credentials: not valid" - val KNORA_AUTHENTICATION_COOKIE_NAME = "KnoraAuthentication" val AUTHENTICATION_INVALIDATION_CACHE_NAME = "authenticationInvalidationCache" val sessionStore: scala.collection.mutable.Map[String, UserADM] = scala.collection.mutable.Map() @@ -580,13 +532,16 @@ object Authenticator extends InstrumentationSupport { * @param requestContext a [[RequestContext]] containing the http request * @return [[KnoraCredentialsV2]]. */ - private def extractCredentialsV2(requestContext: RequestContext): Option[KnoraCredentialsV2] = { + private def extractCredentialsV2( + requestContext: RequestContext, + settings: KnoraSettingsImpl + ): Option[KnoraCredentialsV2] = { // log.debug("extractCredentialsV2 start ...") val credentialsFromParameters: Option[KnoraCredentialsV2] = extractCredentialsFromParametersV2(requestContext) log.debug("extractCredentialsV2 - credentialsFromParameters: {}", credentialsFromParameters) - val credentialsFromHeaders: Option[KnoraCredentialsV2] = extractCredentialsFromHeaderV2(requestContext) + val credentialsFromHeaders: Option[KnoraCredentialsV2] = extractCredentialsFromHeaderV2(requestContext, settings) log.debug("extractCredentialsV2 - credentialsFromHeader: {}", credentialsFromHeaders) // return found credentials based on precedence: 1. url parameters, 2. header (basic auth, token) @@ -608,21 +563,16 @@ object Authenticator extends InstrumentationSupport { */ private def extractCredentialsFromParametersV2(requestContext: RequestContext): Option[KnoraCredentialsV2] = { // extract email/password from parameters - val params: Map[String, Seq[String]] = requestContext.request.uri.query().toMultiMap - // log.debug("extractCredentialsFromParametersV2 - params: {}", params) - // check for iri, email, or username parameters val maybeIriIdentifier: Option[String] = params.get("iri").map(_.head) val maybeEmailIdentifier: Option[String] = params.get("email").map(_.head) val maybeUsernameIdentifier: Option[String] = params.get("username").map(_.head) val maybeIdentifier: Option[String] = List(maybeIriIdentifier, maybeEmailIdentifier, maybeUsernameIdentifier).flatten.headOption - // log.debug("extractCredentialsFromParametersV2 - maybeIdentifier: {}", maybeIdentifier) val maybePassword: Option[String] = params.get("password").map(_.head) - // log.debug("extractCredentialsFromParametersV2 - maybePassword: {}", maybePassword) val maybePassCreds: Option[KnoraPasswordCredentialsV2] = if (maybeIdentifier.nonEmpty && maybePassword.nonEmpty) { Some( @@ -647,9 +597,6 @@ object Authenticator extends InstrumentationSupport { None } - // log.debug("extractCredentialsFromParametersV2 - maybePassCreds: {}", maybePassCreds) - // log.debug("extractCredentialsFromParametersV2 - maybeTokenCreds: {}", maybeTokenCreds) - // prefer password credentials if (maybePassCreds.nonEmpty) { maybePassCreds @@ -671,19 +618,24 @@ object Authenticator extends InstrumentationSupport { * 3. session token * * @param requestContext the HTTP request context. + * @param settings the application settings. * @return an optional [[KnoraCredentialsV2]]. */ - private def extractCredentialsFromHeaderV2(requestContext: RequestContext): Option[KnoraCredentialsV2] = { + private def extractCredentialsFromHeaderV2( + requestContext: RequestContext, + settings: KnoraSettingsImpl + ): Option[KnoraCredentialsV2] = { // Session token from cookie header val cookies: Seq[HttpCookiePair] = requestContext.request.cookies - val maybeSessionCreds: Option[KnoraSessionCredentialsV2] = cookies.find(_.name == "KnoraAuthentication") match { - case Some(authCookie) => - val value: String = authCookie.value - Some(KnoraSessionCredentialsV2(value)) - case None => - None - } + val maybeSessionCreds: Option[KnoraSessionCredentialsV2] = + cookies.find(_.name == calculateCookieName(settings)) match { + case Some(authCookie) => + val value: String = authCookie.value + Some(KnoraSessionCredentialsV2(value)) + case None => + None + } // Authorization header val headers: Seq[HttpHeader] = requestContext.request.headers @@ -763,52 +715,48 @@ object Authenticator extends InstrumentationSupport { val settings = KnoraSettings(system) for { - authenticated <- authenticateCredentialsV2(credentials = credentials) - - user: UserADM <- credentials match { - case Some(passCreds: KnoraPasswordCredentialsV2) => - // log.debug("getUserADMThroughCredentialsV2 - used identifier: {}", passCreds.identifier) - getUserByIdentifier( - identifier = passCreds.identifier - ) - case Some(KnoraJWTTokenCredentialsV2(jwtToken)) => - val userIri: IRI = JWTHelper.extractUserIriFromToken( - jwtToken, - settings.jwtSecretKey, - settings.externalKnoraApiHostPort - ) match { - case Some(iri) => iri - case None => - // should not happen, as the token is already validated - throw AuthenticationException( - "No IRI found inside token. Please report this as a possible bug." - ) - } - // log.debug("getUserADMThroughCredentialsV2 - used token") - getUserByIdentifier( - identifier = UserIdentifierADM(maybeIri = Some(userIri)) - ) - case Some(KnoraSessionCredentialsV2(sessionToken)) => - val userIri: IRI = JWTHelper.extractUserIriFromToken( - sessionToken, - settings.jwtSecretKey, - settings.externalKnoraApiHostPort - ) match { - case Some(iri) => iri - case None => - // should not happen, as the token is already validated - throw AuthenticationException( - "No IRI found inside token. Please report this as a possible bug." - ) - } - // log.debug("getUserADMThroughCredentialsV2 - used session token") - getUserByIdentifier( - identifier = UserIdentifierADM(maybeIri = Some(userIri)) - ) - case None => - // log.debug("getUserADMThroughCredentialsV2 - no credentials supplied") - throw BadCredentialsException(BAD_CRED_NONE_SUPPLIED) - } + _ <- authenticateCredentialsV2(credentials) + + user <- credentials match { + case Some(passCreds: KnoraPasswordCredentialsV2) => + getUserByIdentifier( + identifier = passCreds.identifier + ) + case Some(KnoraJWTTokenCredentialsV2(jwtToken)) => + val userIri: IRI = JWTHelper.extractUserIriFromToken( + jwtToken, + settings.jwtSecretKey, + settings.externalKnoraApiHostPort + ) match { + case Some(iri) => iri + case None => + // should not happen, as the token is already validated + throw AuthenticationException( + "No IRI found inside token. Please report this as a possible bug." + ) + } + getUserByIdentifier( + identifier = UserIdentifierADM(maybeIri = Some(userIri)) + ) + case Some(KnoraSessionCredentialsV2(sessionToken)) => + val userIri: IRI = JWTHelper.extractUserIriFromToken( + sessionToken, + settings.jwtSecretKey, + settings.externalKnoraApiHostPort + ) match { + case Some(iri) => iri + case None => + // should not happen, as the token is already validated + throw AuthenticationException( + "No IRI found inside token. Please report this as a possible bug." + ) + } + getUserByIdentifier( + identifier = UserIdentifierADM(maybeIri = Some(userIri)) + ) + case None => + throw BadCredentialsException(BAD_CRED_NONE_SUPPLIED) + } } yield user } @@ -852,9 +800,24 @@ object Authenticator extends InstrumentationSupport { log.debug(s"getUserByIdentifier - supplied identifier not found - throwing exception") throw BadCredentialsException(s"$BAD_CRED_USER_NOT_FOUND") } - // _ = log.debug(s"getUserByIdentifier - user: $user") } yield user } + + /** + * Calculates the cookie name, where the external host and port are encoded as a base32 string + * to make the name of the cookie unique between environments. + * + * The default padding needs to be changed from '=' to '9' because '=' is not allowed inside the cookie!!! + * This also needs to be changed in all the places that base32 is used to calculate the cookie name, e.g., sipi. + * + * @param settings the application settings. + */ + def calculateCookieName(settings: KnoraSettingsImpl): String = { + // + val base32 = new Base32('9'.toByte) + "KnoraAuthentication" + base32.encodeAsString(settings.externalKnoraApiHostPort.getBytes()) + } + } /** diff --git a/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala b/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala index b6002518ed..fbe88f0833 100644 --- a/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala +++ b/webapi/src/main/scala/org/knora/webapi/settings/KnoraSettings.scala @@ -223,8 +223,6 @@ class KnoraSettingsImpl(config: Config, log: Logger) extends Extension { //used in the store package val tripleStoreConfig: Config = config.getConfig("app.triplestore") - val skipAuthentication: Boolean = config.getBoolean("app.skip-authentication") - val jwtSecretKey: String = config.getString("app.jwt-secret-key") val jwtLongevity: FiniteDuration = getFiniteDuration("app.jwt-longevity", config) diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/admin/SipiADME2ESpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/admin/FilesADME2ESpec.scala similarity index 93% rename from webapi/src/test/scala/org/knora/webapi/e2e/admin/SipiADME2ESpec.scala rename to webapi/src/test/scala/org/knora/webapi/e2e/admin/FilesADME2ESpec.scala index 2415dd5dd1..e477aeb67f 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/admin/SipiADME2ESpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/admin/FilesADME2ESpec.scala @@ -19,13 +19,13 @@ import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject import org.knora.webapi.messages.store.triplestoremessages.TriplestoreJsonProtocol import org.knora.webapi.messages.v1.responder.sessionmessages.SessionJsonProtocol import org.knora.webapi.messages.v1.responder.sessionmessages.SessionResponse -import org.knora.webapi.routing.Authenticator.KNORA_AUTHENTICATION_COOKIE_NAME import org.knora.webapi.sharedtestdata.SharedTestDataV1 import scala.concurrent.Await import scala.concurrent.duration._ +import org.knora.webapi.routing.Authenticator -object SipiADME2ESpec { +object FilesADME2ESpec { val config: Config = ConfigFactory.parseString(""" akka.loglevel = "DEBUG" akka.stdout-loglevel = "DEBUG" @@ -37,7 +37,7 @@ object SipiADME2ESpec { * * This spec tests the 'admin/files'. */ -class SipiADME2ESpec extends E2ESpec(SipiADME2ESpec.config) with SessionJsonProtocol with TriplestoreJsonProtocol { +class FilesADME2ESpec extends E2ESpec(FilesADME2ESpec.config) with SessionJsonProtocol with TriplestoreJsonProtocol { private implicit def default(implicit system: ActorSystem) = RouteTestTimeout(30.seconds) @@ -47,6 +47,8 @@ class SipiADME2ESpec extends E2ESpec(SipiADME2ESpec.config) with SessionJsonProt private val normalUserEmailEnc = java.net.URLEncoder.encode(normalUserEmail, "utf-8") private val testPass = java.net.URLEncoder.encode("test", "utf-8") + val KnoraAuthenticationCookieName = Authenticator.calculateCookieName(settings) + override lazy val rdfDataObjects = List( RdfDataObject(path = "test_data/all_data/anything-data.ttl", name = "http://www.knora.org/data/0001/anything") ) @@ -62,7 +64,7 @@ class SipiADME2ESpec extends E2ESpec(SipiADME2ESpec.config) with SessionJsonProt } def sessionLogout(sessionId: String): Unit = - Get(baseApiUrl + "/v1/session?logout") ~> Cookie(KNORA_AUTHENTICATION_COOKIE_NAME, sessionId) + Get(baseApiUrl + "/v1/session?logout") ~> Cookie(KnoraAuthenticationCookieName, sessionId) "The Files Route ('admin/files') using token credentials" should { @@ -132,7 +134,7 @@ class SipiADME2ESpec extends E2ESpec(SipiADME2ESpec.config) with SessionJsonProt /* anything image */ val request = Get(baseApiUrl + s"/admin/files/0001/B1D0OkEgfFp-Cew2Seur7Wi.jp2") ~> Cookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + KnoraAuthenticationCookieName, sessionId ) val response: HttpResponse = singleAwaitingRequest(request) @@ -156,7 +158,7 @@ class SipiADME2ESpec extends E2ESpec(SipiADME2ESpec.config) with SessionJsonProt /* anything image */ val request = Get(baseApiUrl + s"/admin/files/0001/B1D0OkEgfFp-Cew2Seur7Wi.jp2") ~> Cookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + KnoraAuthenticationCookieName, sessionId ) val response: HttpResponse = singleAwaitingRequest(request) diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v1/AuthenticationV1E2ESpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/v1/AuthenticationV1E2ESpec.scala index faf75a06d1..574f9f3d41 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v1/AuthenticationV1E2ESpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v1/AuthenticationV1E2ESpec.scala @@ -17,11 +17,11 @@ import org.knora.webapi.E2ESpec import org.knora.webapi.messages.store.triplestoremessages.TriplestoreJsonProtocol import org.knora.webapi.messages.v1.responder.sessionmessages.SessionJsonProtocol import org.knora.webapi.messages.v1.responder.sessionmessages.SessionResponse -import org.knora.webapi.routing.Authenticator.KNORA_AUTHENTICATION_COOKIE_NAME import org.knora.webapi.sharedtestdata.SharedTestDataV1 import scala.concurrent.Await import scala.concurrent.duration._ +import org.knora.webapi.routing.Authenticator object AuthenticationV1E2ESpec { val config: Config = ConfigFactory.parseString(""" @@ -53,6 +53,8 @@ class AuthenticationV1E2ESpec private val testPass = java.net.URLEncoder.encode("test", "utf-8") private val wrongPass = java.net.URLEncoder.encode("wrong", "utf-8") + val KnoraAuthenticationCookieName = Authenticator.calculateCookieName(settings) + "The Authentication Route ('v1/authenticate') with credentials supplied via URL parameters" should { "succeed authentication with correct email and correct password" in { @@ -104,8 +106,6 @@ class AuthenticationV1E2ESpec val response: HttpResponse = singleAwaitingRequest(request) assert(response.status == StatusCodes.OK) - //println(response.toString) - val sr: SessionResponse = Await.result(Unmarshal(response.entity).to[SessionResponse], 1.seconds) sid = sr.sid @@ -113,7 +113,7 @@ class AuthenticationV1E2ESpec response.headers.contains( `Set-Cookie`( HttpCookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + KnoraAuthenticationCookieName, value = sid, domain = Some(settings.cookieDomain), path = Some("/"), @@ -130,12 +130,10 @@ class AuthenticationV1E2ESpec } "not return sensitive information (token, password) in the response when checking session" in { - val request = Get(baseApiUrl + s"/v1/session") ~> Cookie(KNORA_AUTHENTICATION_COOKIE_NAME, value = sid) + val request = Get(baseApiUrl + s"/v1/session") ~> Cookie(KnoraAuthenticationCookieName, value = sid) val response = singleAwaitingRequest(request) assert(response.status === StatusCodes.OK) - //println(response.toString) - val body: String = Await.result(Unmarshal(response.entity).to[String], 1.seconds) assert(body contains "\"password\":null") assert(body contains "\"token\":null") @@ -143,24 +141,21 @@ class AuthenticationV1E2ESpec "succeed authentication with correct session id in cookie" in { // authenticate by calling '/v1/session' without parameters but by providing session id in cookie from earlier login - val request = Get(baseApiUrl + "/v1/session") ~> Cookie(KNORA_AUTHENTICATION_COOKIE_NAME, sid) + val request = Get(baseApiUrl + "/v1/session") ~> Cookie(KnoraAuthenticationCookieName, sid) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.OK) - } "succeed 'logout' with provided session cookie" in { // do logout with stored session id - val request = Get(baseApiUrl + "/v1/session?logout") ~> Cookie(KNORA_AUTHENTICATION_COOKIE_NAME, sid) + val request = Get(baseApiUrl + "/v1/session?logout") ~> Cookie(KnoraAuthenticationCookieName, sid) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.OK) assert( response.headers.contains( `Set-Cookie`( HttpCookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + KnoraAuthenticationCookieName, "", domain = Some(settings.cookieDomain), path = Some("/"), @@ -174,9 +169,8 @@ class AuthenticationV1E2ESpec } "fail authentication with provided session cookie after logout" in { - val request = Get(baseApiUrl + "/v1/session") ~> Cookie(KNORA_AUTHENTICATION_COOKIE_NAME, sid) + val request = Get(baseApiUrl + "/v1/session") ~> Cookie(KnoraAuthenticationCookieName, sid) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } @@ -184,7 +178,6 @@ class AuthenticationV1E2ESpec /* Correct username and wrong password */ val request = Get(baseApiUrl + s"/v1/session?login&email=$rootEmailEnc&password=$wrongPass") val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } @@ -192,14 +185,12 @@ class AuthenticationV1E2ESpec /* wrong username */ val request = Get(baseApiUrl + s"/v1/session?login&email=$wrongEmailEnc&password=$testPass") val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } "fail authentication with wrong session id in cookie" in { - val request = Get(baseApiUrl + "/v1/session") ~> Cookie(KNORA_AUTHENTICATION_COOKIE_NAME, "123456") + val request = Get(baseApiUrl + "/v1/session") ~> Cookie(KnoraAuthenticationCookieName, "123456") val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } } @@ -210,7 +201,6 @@ class AuthenticationV1E2ESpec /* Correct username and correct password */ val request = Get(baseApiUrl + "/v1/session?login") ~> addCredentials(BasicHttpCredentials(rootEmail, testPass)) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.OK) val sr: SessionResponse = Await.result(Unmarshal(response.entity).to[SessionResponse], 1.seconds) @@ -220,7 +210,7 @@ class AuthenticationV1E2ESpec response.headers.contains( `Set-Cookie`( HttpCookie( - KNORA_AUTHENTICATION_COOKIE_NAME, + KnoraAuthenticationCookieName, value = sid, domain = Some(settings.cookieDomain), path = Some("/"), @@ -237,12 +227,10 @@ class AuthenticationV1E2ESpec } "not return sensitive information (token, password) in the response when checking session" in { - val request = Get(baseApiUrl + s"/v1/session") ~> Cookie(KNORA_AUTHENTICATION_COOKIE_NAME, sid) + val request = Get(baseApiUrl + s"/v1/session") ~> Cookie(KnoraAuthenticationCookieName, sid) val response = singleAwaitingRequest(request) assert(response.status === StatusCodes.OK) - //println(response.toString) - val body: String = Await.result(Unmarshal(response.entity).to[String], 1.seconds) assert(body contains "\"password\":null") assert(body contains "\"token\":null") @@ -252,7 +240,6 @@ class AuthenticationV1E2ESpec /* Correct username and wrong password */ val request = Get(baseApiUrl + "/v1/session?login") ~> addCredentials(BasicHttpCredentials(rootEmail, wrongPass)) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } @@ -260,7 +247,6 @@ class AuthenticationV1E2ESpec /* wrong username */ val request = Get(baseApiUrl + "/v1/session?login") ~> addCredentials(BasicHttpCredentials(wrongEmail, testPass)) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } } @@ -270,7 +256,6 @@ class AuthenticationV1E2ESpec /* Correct email / correct password */ val request = Get(baseApiUrl + s"/v1/users/$rootIriEnc?email=$rootEmailEnc&password=$testPass") val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.OK) } @@ -279,7 +264,6 @@ class AuthenticationV1E2ESpec val request = Get(baseApiUrl + s"/v1/users/$rootIriEnc?email=$rootEmailEnc&password=$wrongPass") val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } @@ -288,7 +272,6 @@ class AuthenticationV1E2ESpec val request = Get(baseApiUrl + s"/v1/users/$rootIriEnc") ~> addCredentials(BasicHttpCredentials(rootEmail, testPass)) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.OK) } @@ -297,15 +280,13 @@ class AuthenticationV1E2ESpec val request = Get(baseApiUrl + s"/v1/users/$rootIriEnc") ~> addCredentials(BasicHttpCredentials(rootEmail, wrongPass)) val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) assert(response.status === StatusCodes.Unauthorized) } "not return sensitive information (token, password) in the response " in { val request = Get(baseApiUrl + s"/v1/users/$rootIriEnc?email=$rootEmailEnc&password=$testPass") val response = singleAwaitingRequest(request) - //log.debug("==>> " + responseAs[String]) - // assert(status === StatusCodes.OK) + assert(response.status === StatusCodes.OK) /* check for sensitive information leakage */ val body: String = Await.result(Unmarshal(response.entity).to[String], 1.seconds) diff --git a/webapi/src/test/scala/org/knora/webapi/routing/AuthenticatorSpec.scala b/webapi/src/test/scala/org/knora/webapi/routing/AuthenticatorSpec.scala index de468d26bc..a5e5e7415c 100644 --- a/webapi/src/test/scala/org/knora/webapi/routing/AuthenticatorSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/routing/AuthenticatorSpec.scala @@ -175,5 +175,11 @@ class AuthenticatorSpec extends CoreSpec("AuthenticationTestSystem") with Implic } } + + "called, the 'calculateCookieName' method" should { + "succeed with generating the name" in { + Authenticator.calculateCookieName(settings) should equal("KnoraAuthenticationGAXDALRQFYYDUMZTGMZQ9999") + } + } } }