Skip to content

Commit 74daa05

Browse files
authored
feat: Set the auth cookie with expiration and when the token is valid on GET /v2/authentication (DEV-5783) (#3925)
<!-- Important! Please follow the guidelines for naming Pull Requests: https://docs.dasch.swiss/latest/developers/contribution/ --> ### Description <!-- Please add a short description of the changes --> <!-- * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) --> <!-- * **What is the current behavior?** (You can also link to an open issue here) --> <!-- * **What is the new behavior (if this is a feature change)?** --> <!-- * **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?) --> <!-- * **Other information**: -->
1 parent df84c19 commit 74daa05

File tree

8 files changed

+112
-48
lines changed

8 files changed

+112
-48
lines changed

modules/test-e2e/src/test/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2E2ESpec.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import org.knora.webapi.slice.api.v2.authentication.AuthenticationEndpointsV2.Lo
2020
import org.knora.webapi.slice.api.v2.authentication.AuthenticationEndpointsV2.TokenResponse
2121
import org.knora.webapi.testservices.ResponseOps.*
2222
import org.knora.webapi.testservices.TestApiClient
23+
2324
object AuthenticationEndpointsV2E2ESpec extends E2EZSpec {
2425

2526
private val validPassword = "test"
@@ -85,7 +86,7 @@ object AuthenticationEndpointsV2E2ESpec extends E2EZSpec {
8586
checkAfterLogout <- TestApiClient.getJson[CheckResponse](uri"/v2/authentication", _.auth.bearer(token))
8687
} yield assertTrue(
8788
checkAfterLogout.code == StatusCode.Unauthorized,
88-
checkAfterLogout.body == Right(CheckResponse("Invalid credentials.")),
89+
checkAfterLogout.body == Right(CheckResponse("bad credentials: not valid")),
8990
)
9091
},
9192
),

webapi/src/main/scala/org/knora/webapi/slice/api/v2/authentication/AuthenticationEndpointsV2.scala

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import sttp.tapir.*
1010
import sttp.tapir.codec.refined.*
1111
import sttp.tapir.generic.auto.*
1212
import sttp.tapir.json.zio.jsonBody
13+
import sttp.tapir.model.UsernamePassword
14+
import sttp.tapir.ztapir.auth
1315
import zio.*
1416
import zio.json.*
1517
import zio.json.internal.Write
@@ -21,16 +23,19 @@ import org.knora.webapi.slice.common.api.BaseEndpoints
2123
import org.knora.webapi.slice.infrastructure.Jwt
2224
import org.knora.webapi.slice.security.Authenticator
2325

24-
case class AuthenticationEndpointsV2(
25-
private val baseEndpoints: BaseEndpoints,
26-
private val authenticator: Authenticator,
26+
final class AuthenticationEndpointsV2(
27+
baseEndpoints: BaseEndpoints,
28+
authenticator: Authenticator,
2729
) {
2830

2931
private val basePath: EndpointInput[Unit] = "v2" / "authentication"
3032
private val cookieName = authenticator.calculateCookieName()
3133

32-
val getV2Authentication = baseEndpoints.securedEndpoint.get
34+
val getV2Authentication = baseEndpoints.publicEndpoint.get
3335
.in(basePath)
36+
.in(auth.bearer[Option[String]](WWWAuthenticateChallenge.bearer))
37+
.in(auth.basic[Option[UsernamePassword]](WWWAuthenticateChallenge.basic("realm")))
38+
.out(setCookieOpt(cookieName))
3439
.out(jsonBody[CheckResponse])
3540

3641
val postV2Authentication = baseEndpoints.publicEndpoint.post
@@ -53,6 +58,7 @@ object AuthenticationEndpointsV2 {
5358

5459
final case class CheckResponse(message: String)
5560
object CheckResponse {
61+
val OK = CheckResponse("credentials are OK")
5662
given JsonCodec[CheckResponse] = DeriveJsonCodec.gen[CheckResponse]
5763
}
5864

webapi/src/main/scala/org/knora/webapi/slice/api/v2/authentication/AuthenticationRestService.scala

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import java.time.Instant
1212

1313
import dsp.errors.BadCredentialsException
1414
import org.knora.webapi.config.AppConfig
15+
import org.knora.webapi.slice.admin.domain.model.Email
16+
import org.knora.webapi.slice.api.v2.authentication.AuthenticationEndpointsV2.CheckResponse
1517
import org.knora.webapi.slice.api.v2.authentication.AuthenticationEndpointsV2.LoginPayload
1618
import org.knora.webapi.slice.api.v2.authentication.AuthenticationEndpointsV2.LoginPayload.EmailPassword
1719
import org.knora.webapi.slice.api.v2.authentication.AuthenticationEndpointsV2.LoginPayload.IriPassword
@@ -22,47 +24,83 @@ import org.knora.webapi.slice.infrastructure.Jwt
2224
import org.knora.webapi.slice.security.Authenticator
2325
import org.knora.webapi.slice.security.Authenticator.BAD_CRED_NOT_VALID
2426

25-
final case class AuthenticationRestService(
26-
private val authenticator: Authenticator,
27-
private val appConfig: AppConfig,
27+
final class AuthenticationRestService(
28+
authenticator: Authenticator,
29+
appConfig: AppConfig,
2830
) {
2931

32+
def checkAuthentication(
33+
token: Option[String],
34+
usernamePassword: Option[sttp.tapir.model.UsernamePassword],
35+
): IO[BadCredentialsException, (Option[CookieValueWithMeta], CheckResponse)] =
36+
(token, usernamePassword) match {
37+
case (None, None) => ZIO.fail(BadCredentialsException(BAD_CRED_NOT_VALID))
38+
case (Some(jwtString), None) =>
39+
authenticator
40+
.parseToken(jwtString)
41+
.mapBoth(
42+
_ => BadCredentialsException(BAD_CRED_NOT_VALID),
43+
jwt => (Some(setCookie(jwt)), CheckResponse.OK),
44+
)
45+
case (None, Some(usernamePassword)) =>
46+
for {
47+
email <- ZIO
48+
.fromEither(Email.from(usernamePassword.username))
49+
.orElseFail(BadCredentialsException(BAD_CRED_NOT_VALID))
50+
password <- ZIO
51+
.fromOption(usernamePassword.password)
52+
.orElseFail(BadCredentialsException(BAD_CRED_NOT_VALID))
53+
resp <- authenticator
54+
.authenticate(email, password)
55+
.mapBoth(
56+
_ => BadCredentialsException(BAD_CRED_NOT_VALID),
57+
_ => (None, CheckResponse.OK),
58+
)
59+
} yield resp
60+
case (Some(_), Some(_)) =>
61+
ZIO.fail(BadCredentialsException("Provide either a JWT token or basic auth credentials, not both."))
62+
}
63+
64+
private def setCookie(jwt: Jwt) = {
65+
val expiresAt = Instant.ofEpochSecond(jwt.expiration)
66+
val maxAgeSeconds = jwt.expiration - Instant.now().getEpochSecond
67+
CookieValueWithMeta.unsafeApply(
68+
domain = Some(appConfig.cookieDomain),
69+
expires = Some(expiresAt),
70+
maxAge = Some(maxAgeSeconds),
71+
httpOnly = true,
72+
path = Some("/"),
73+
value = jwt.jwtString,
74+
)
75+
}
76+
77+
private val removeCookie = CookieValueWithMeta.unsafeApply(
78+
domain = Some(appConfig.cookieDomain),
79+
expires = Some(Instant.EPOCH),
80+
httpOnly = true,
81+
maxAge = Some(0),
82+
path = Some("/"),
83+
value = "",
84+
)
85+
3086
def authenticate(login: LoginPayload): IO[BadCredentialsException, (CookieValueWithMeta, TokenResponse)] =
3187
(login match {
3288
case IriPassword(iri, password) => authenticator.authenticate(iri, password)
3389
case UsernamePassword(username, password) => authenticator.authenticate(username, password)
3490
case EmailPassword(email, password) => authenticator.authenticate(email, password)
35-
}).mapBoth(_ => BadCredentialsException(BAD_CRED_NOT_VALID), (_, token) => setCookieAndResponse(token))
36-
37-
private def setCookieAndResponse(token: Jwt) =
38-
(
39-
CookieValueWithMeta.unsafeApply(
40-
domain = Some(appConfig.cookieDomain),
41-
httpOnly = true,
42-
path = Some("/"),
43-
value = token.jwtString,
44-
),
45-
TokenResponse(token.jwtString),
91+
}).mapBoth(
92+
_ => BadCredentialsException(BAD_CRED_NOT_VALID),
93+
(_, jwt) => (setCookie(jwt), TokenResponse(jwt)),
4694
)
4795

48-
def logout(tokenFromBearer: Option[String], tokenFromCookie: Option[String]) =
96+
def logout(
97+
tokenFromBearer: Option[String],
98+
tokenFromCookie: Option[String],
99+
): UIO[(CookieValueWithMeta, LogoutResponse)] =
49100
ZIO
50101
.foreachDiscard(Set(tokenFromBearer, tokenFromCookie).flatten)(authenticator.invalidateToken)
51102
.ignore
52-
.as {
53-
(
54-
CookieValueWithMeta.unsafeApply(
55-
domain = Some(appConfig.cookieDomain),
56-
expires = Some(Instant.EPOCH),
57-
httpOnly = true,
58-
maxAge = Some(0),
59-
path = Some("/"),
60-
value = "",
61-
),
62-
LogoutResponse(0, "Logout OK"),
63-
)
64-
}
65-
103+
.as((removeCookie, LogoutResponse(0, "Logout OK")))
66104
}
67105

68106
object AuthenticationRestService {

webapi/src/main/scala/org/knora/webapi/slice/api/v2/authentication/AuthenticationServerEndpoints.scala

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,12 @@ package org.knora.webapi.slice.api.v2.authentication
88
import sttp.tapir.ztapir.*
99
import zio.*
1010

11-
import org.knora.webapi.slice.api.v2.authentication.AuthenticationEndpointsV2.CheckResponse
12-
1311
final class AuthenticationServerEndpoints(
1412
restService: AuthenticationRestService,
1513
endpoints: AuthenticationEndpointsV2,
1614
) {
1715
val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List(
18-
endpoints.getV2Authentication.serverLogic(_ => _ => ZIO.succeed(CheckResponse("credentials are OK"))),
16+
endpoints.getV2Authentication.zServerLogic(restService.checkAuthentication),
1917
endpoints.postV2Authentication.zServerLogic(restService.authenticate),
2018
endpoints.deleteV2Authentication.zServerLogic(restService.logout),
2119
)

webapi/src/main/scala/org/knora/webapi/slice/infrastructure/JwtService.scala

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import pdi.jwt.JwtHeader
1111
import pdi.jwt.JwtZIOJson
1212
import zio.Clock
1313
import zio.Duration
14+
import zio.IO
1415
import zio.Random
1516
import zio.Task
1617
import zio.UIO
@@ -21,6 +22,7 @@ import zio.json.ast.Json
2122

2223
import scala.util.Success
2324

25+
import dsp.errors.BadCredentialsException
2426
import dsp.valueobjects.Iri
2527
import dsp.valueobjects.UuidUtil
2628
import org.knora.webapi.IRI
@@ -54,7 +56,7 @@ trait JwtService {
5456
* @param token the JWT.
5557
* @return a [[Boolean]].
5658
*/
57-
def isTokenValid(token: String): Boolean
59+
def parseToken(token: String): IO[BadCredentialsException, Jwt]
5860

5961
/**
6062
* Extracts the encoded user IRI. This method also makes sure that the required headers and claims are present.
@@ -115,9 +117,19 @@ final case class JwtServiceLive(
115117
* present.
116118
*
117119
* @param token the JWT.
118-
* @return a [[Boolean]].
120+
* @return the Jwt or fails with BadCredentialsException.
119121
*/
120-
override def isTokenValid(token: String): Boolean = !cache.contains(token) && decodeToken(token).isDefined
122+
override def parseToken(str: String): IO[BadCredentialsException, Jwt] =
123+
if (cache.contains(str)) {
124+
ZIO.fail(BadCredentialsException("Invalid JWT token"))
125+
} else {
126+
decodeToken(str) match {
127+
case Some((_, claim)) =>
128+
ZIO.succeed(Jwt(str, claim.expiration.getOrElse(0L)))
129+
case None =>
130+
ZIO.fail(BadCredentialsException("Invalid JWT token"))
131+
}
132+
}
121133

122134
/**
123135
* Extracts the encoded user IRI. This method also makes sure that the required headers and claims are present.

webapi/src/main/scala/org/knora/webapi/slice/security/Authenticator.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ trait Authenticator {
4545
def calculateCookieName(): String
4646

4747
def invalidateToken(jwt: String): IO[AuthenticatorError, Unit]
48+
def parseToken(jwt: String): IO[AuthenticatorError, Jwt]
4849
def authenticate(userIri: UserIri, password: String): IO[AuthenticatorError, (User, Jwt)]
4950
def authenticate(username: Username, password: String): IO[AuthenticatorError, (User, Jwt)]
5051
def authenticate(email: Email, password: String): IO[AuthenticatorError, (User, Jwt)]
@@ -64,6 +65,11 @@ final case class AuthenticatorLive(
6465
private val invalidTokens: InvalidTokenCache,
6566
) extends Authenticator {
6667

68+
override def parseToken(jwt: String): IO[AuthenticatorError, Jwt] = for {
69+
_ <- ZIO.fail(BadCredentials).when(invalidTokens.contains(jwt))
70+
jwt <- jwtService.parseToken(jwt).orElseFail(AuthenticatorError.BadCredentials)
71+
} yield jwt
72+
6773
override def authenticate(userIri: UserIri, password: String): IO[AuthenticatorError, (User, Jwt)] = for {
6874
user <- getUserByIri(userIri)
6975
_ <- ensurePasswordMatch(user, password)

webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/DspIngestClientLiveSpec.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo
2121
import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
2222
import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder
2323
import zio.Console
24+
import zio.IO
2425
import zio.Random
2526
import zio.Scope
2627
import zio.Task
@@ -40,6 +41,7 @@ import zio.test.TestEnvironment
4041
import zio.test.ZIOSpecDefault
4142
import zio.test.assertTrue
4243

44+
import dsp.errors.BadCredentialsException
4345
import org.knora.webapi.IRI
4446
import org.knora.webapi.config.DspIngestConfig
4547
import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode
@@ -137,7 +139,7 @@ object DspIngestClientLiveSpecLayers {
137139
new JwtService {
138140
override def createJwtForDspIngest(): UIO[Jwt] = ZIO.succeed(Jwt("mock-jwt-string-value", Long.MaxValue))
139141
override def createJwt(user: UserIri, scope: AuthScope, content: Map[String, Json]): UIO[Jwt] = unsupported
140-
override def isTokenValid(token: String): Boolean = throw new UnsupportedOperationException("not implemented")
142+
override def parseToken(token: String): IO[BadCredentialsException, Jwt] = unsupported
141143
override def extractUserIriFromToken(token: String): Task[Option[IRI]] = unsupported
142144
}
143145
}

webapi/src/test/scala/org/knora/webapi/slice/infrastructure/JwtServiceLiveSpec.scala

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import zio.test.Spec
2222
import zio.test.TestAspect
2323
import zio.test.TestEnvironment
2424
import zio.test.ZIOSpecDefault
25+
import zio.test.assertCompletes
2526
import zio.test.assertTrue
2627
import zio.test.check
2728

@@ -146,9 +147,9 @@ object JwtServiceLiveSpec extends ZIOSpecDefault {
146147
},
147148
test("validate a self issued token") {
148149
for {
149-
token <- JwtService(_.createJwt(user.userIri, AuthScope.empty))
150-
isValid <- ZIO.serviceWith[JwtService](_.isTokenValid(token.jwtString))
151-
} yield assertTrue(isValid)
150+
token <- JwtService(_.createJwt(user.userIri, AuthScope.empty))
151+
_ <- ZIO.serviceWith[JwtService](_.parseToken(token.jwtString))
152+
} yield assertCompletes
152153
},
153154
test("fail to validate an invalid token") {
154155
def createClaim(
@@ -176,10 +177,10 @@ object JwtServiceLiveSpec extends ZIOSpecDefault {
176177
Gen.fromIterable(Seq(issuerMissing, invalidSubject, missingAudience, missingIat, expired, missingJwtId)),
177178
) { claim =>
178179
for {
179-
secret <- ZIO.serviceWith[JwtConfig](_.secret)
180-
token = JwtZIOJson.encode("""{"typ":"JWT","alg":"HS256"}""", claim.toJson, secret, JwtAlgorithm.HS256)
181-
isValid <- ZIO.serviceWith[JwtService](_.isTokenValid(token))
182-
} yield assertTrue(!isValid)
180+
secret <- ZIO.serviceWith[JwtConfig](_.secret)
181+
token = JwtZIOJson.encode("""{"typ":"JWT","alg":"HS256"}""", claim.toJson, secret, JwtAlgorithm.HS256)
182+
exit <- ZIO.serviceWithZIO[JwtService](_.parseToken(token).exit)
183+
} yield assertTrue(exit.isFailure)
183184
}
184185
},
185186
) @@ TestAspect.withLiveEnvironment @@ TestAspect.beforeAll(ZIO.serviceWith[CacheManager](_.clearAll())))

0 commit comments

Comments
 (0)