Skip to content

Commit 263a744

Browse files
authored
Webflux Updates (#3)
* adds empty async controller to be used later * basic webflux implementation * remove logging from json encoder * add lightweight benchmarking script to help with optimizing async setup * benchmarking script tweak * update readme * adds geometry data to Address to demonstrate postgis integration. also adds to testutils. * better table enumeration for resetting persistence in tests * update readme * update readme * adds benchmark endpoint * adds more or less working register/login functionality, along with a partial jwt auth skeleton * adds basic working authentication / authorization via Spring Security + JWT
1 parent 710dc97 commit 263a744

21 files changed

Lines changed: 919 additions & 89 deletions

README.md

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,11 @@
33
# Overview
44
Demonstrates using Spring Framework with Scala 3.
55

6-
Since the initial release of Scala 3 I've considered upgrading various
7-
non-trivial personal and professional projects from 2.13 to 3, but until recently (2025) always ultimately declined to upgrade.
8-
9-
After getting over the deprecation hurdles,
10-
The biggest headaches have been with the level of compatibility tools like IntellIJ offer, and the relatively small market share
11-
scala 3 currently has of Scala projects and libraries.
12-
Builds can also slow to a crawl or hang during non-trivial refactorings. Compile times
13-
are also up, and builds will randomly fail, only to succeed after a second or third retry.
6+
The biggest headaches of upgrading from Scala 2.13 to Scala 3 has been with the level of compatibility tools like IntellIJ offer, and the relatively small market share
7+
it has of Scala projects and libraries. Builds sometimes slow to a crawl or hang or randomly fail, only to succeed after a second or third retry.
148

159
The jury is still out on whether the migration is worthwhile for established projects, but
16-
as of 2025 I do feel like Scala 3 is the way to go.
17-
18-
I'll try to keep this project updated
19-
with things I learn as I go.
10+
as of 2025 I do feel like Scala 3 is the way to go for new projects.
2011

2112
:speech_balloon: **Questions / comments / suggestions are welcome in the [discussions](https://github.com/halfhp/ScalaSpringExperiment/discussions), or feel free to [contact me](mailto:halfhp@gmail.com) directly.**
2213

@@ -81,13 +72,13 @@ This means that whenever you are working with variables coming Spring, you gener
8172
One particular place to watch out for this is when using Spring's `@RequestParam` and `@PathVariable` annotations in controllers.
8273

8374
## Spring's ThreadLocal Context
84-
Much of Spring's async programming model relies on ThreadLocal context, particularly when using WebMVC. This used to be a common pattern in Java, but not one that is used in Scala.
85-
This becomes particularly annoying when interfacing between things like controller entry points and services and utilities that are built
75+
Parts of Spring's architecture relies on ThreadLocal context, particularly when using WebMVC.
76+
This can be problematic when interfacing between things like controller entry points and services and utilities that are built
8677
around IO/Future/ZIO etc. monads. Effectively, trying to access something like Spring Security's SecurityContext from these methods
8778
will not work. Without going into too much detail WebFlux has the same basic problem, even though its not technically using ThreadLocal context.
8879

89-
The best solution I have found is to pass the SecurityContext and any other ThreadLocal / pseudo global context data as an argument to
90-
these methods. This is not ideal, but it is the best solution I have found so far.
80+
My preferred solution is to pass the SecurityContext and any other ThreadLocal / pseudo global context data as an argument to
81+
these methods.
9182

9283
## Async Programming
9384
Spring has it's own mechanisms for async programming, and it takes some work to adapt it to be compatible with IO monads.

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ dependencies {
3030
implementation 'org.springframework.security:spring-security-test'
3131
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
3232

33+
implementation 'com.github.jwt-scala:jwt-circe_3:10.0.4'
34+
3335
implementation 'net.postgis:postgis-jdbc:2024.1.0'
3436

3537
implementation 'io.circe:circe-core_3:0.14.12'

extras/taurus/benchmark_localhost.sh

100644100755
File mode changed.

extras/taurus/tests/test.yml

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,54 @@
1111
# - "-Xmx512m"
1212

1313
execution:
14+
- concurrency: 1
15+
name: "serial conc=1, 10ms, 1x80"
16+
scenario: serial_10ms_1x80
17+
ramp-up: 1s
18+
hold-for: 30s
19+
sequential: true
20+
- concurrency: 1
21+
name: "parallel conc=1, 10ms, 4x80"
22+
scenario: parallel_10ms_4x80
23+
ramp-up: 1s
24+
hold-for: 30s
25+
sequential: true
1426
- concurrency: 10
15-
ramp-up: 30s
16-
hold-for: 1m
17-
scenario: simple
27+
name: "serial conc=10, 10ms, 1x80"
28+
scenario: serial_10ms_1x80
29+
ramp-up: 1s
30+
hold-for: 30s
31+
sequential: true
32+
- concurrency: 10
33+
name: "parallel conc=10, 10ms, 4x80"
34+
scenario: parallel_10ms_4x80
35+
ramp-up: 1s
36+
hold-for: 30s
37+
sequential: true
38+
- concurrency: 100
39+
name: "serial conc=100, 10ms, 1x80"
40+
scenario: serial_10ms_1x80
41+
ramp-up: 1s
42+
hold-for: 30s
43+
sequential: true
44+
- concurrency: 100
45+
name: "parallel conc=100, 10ms, 4x80"
46+
scenario: parallel_10ms_4x80
47+
ramp-up: 5s
48+
hold-for: 30s
49+
sequential: true
1850

1951
scenarios:
20-
simple:
52+
serial_10ms_1x80:
53+
requests:
54+
- url: http://host.docker.internal:8080/benchmark?count=80&durationMs=10&parallelism=1
55+
method: GET
56+
parallel_10ms_4x80:
2157
requests:
22-
- url: http://host.docker.internal:8080/
58+
- url: http://host.docker.internal:8080/benchmark?count=80&durationMs=10&parallelism=4
2359
method: GET
2460

61+
2562
#settings:
2663
# artifacts-dir: /output
2764

src/main/resources/db/migration/V1.0.sql

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,22 @@ CREATE TRIGGER set_address_timestamps
4444
BEFORE INSERT OR UPDATE ON address
4545
FOR EACH ROW
4646
EXECUTE FUNCTION set_timestamp_fields();
47+
48+
CREATE TABLE registered_user (
49+
id BIGSERIAL PRIMARY KEY,
50+
date_created timestamp not null,
51+
last_updated timestamp not null,
52+
email varchar unique not null,
53+
email_verified boolean not null,
54+
roles varchar[] not null,
55+
password_hash varchar not null,
56+
person_id BIGINT not null
57+
constraint registered_user_person_id_fk
58+
references person
59+
on delete cascade
60+
);
61+
62+
CREATE TRIGGER set_registered_user_timestamps
63+
BEFORE INSERT OR UPDATE ON registered_user
64+
FOR EACH ROW
65+
EXECUTE FUNCTION set_timestamp_fields();

src/main/scala/com/example/scalaspringexperiment/SpringConfig.scala

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,53 +2,77 @@ package com.example.scalaspringexperiment
22

33
import cats.effect.unsafe.IORuntime
44
import cats.effect.{IO, Resource}
5+
import com.example.scalaspringexperiment.auth.{JwtAuthManager, JwtServerAuthConverter}
56
import com.example.scalaspringexperiment.util.{CirceJsonDecoder, CirceJsonEncoder}
67
import doobie.{DataSourceTransactor, ExecutionContexts}
78
import doobie.util.transactor.Transactor
8-
import org.springframework.context.annotation.{Bean, Configuration}
9+
import org.springframework.context.annotation.{Bean, Configuration, Primary}
10+
import org.springframework.http.HttpStatus
911
import org.springframework.http.codec.ServerCodecConfigurer
12+
import org.springframework.security.authentication.{DelegatingReactiveAuthenticationManager, UsernamePasswordAuthenticationToken}
1013
import org.springframework.security.config.Customizer
1114
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity
1215
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
13-
import org.springframework.security.config.web.server.ServerHttpSecurity
16+
import org.springframework.security.config.web.server.{SecurityWebFiltersOrder, ServerHttpSecurity}
1417
import org.springframework.security.web.server.SecurityWebFilterChain
15-
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository
18+
import org.springframework.security.web.server.authentication.AuthenticationWebFilter
19+
import org.springframework.security.web.server.context.{NoOpServerSecurityContextRepository, WebSessionServerSecurityContextRepository}
1620
import org.springframework.web.reactive.config.WebFluxConfigurer
1721

1822
import javax.sql.DataSource
1923

24+
2025
@Configuration
21-
@EnableWebFluxSecurity
22-
@EnableReactiveMethodSecurity
2326
class SpringConfig(
2427
dataSource: DataSource,
2528
) {
2629

27-
@Bean
28-
def catsEffectIORuntime(): IORuntime = {
29-
cats.effect.unsafe.implicits.global
30-
}
31-
3230
@Bean
3331
def doobieTransactor(): Resource[IO, DataSourceTransactor[IO]] = {
3432
for {
3533
ce <- ExecutionContexts.fixedThreadPool[IO](32) // our connect EC
3634
} yield Transactor.fromDataSource[IO](dataSource, ce)
3735
}
36+
}
3837

38+
@Configuration
39+
@EnableWebFluxSecurity
40+
@EnableReactiveMethodSecurity
41+
class SecurityConfig(
42+
jwtAuthManager: JwtAuthManager,
43+
) {
3944
@Bean
40-
def securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = {
45+
def securityFilterChain(
46+
http: ServerHttpSecurity,
47+
jwtAuthFilter: AuthenticationWebFilter,
48+
): SecurityWebFilterChain = {
4149
http
4250
.cors(Customizer.withDefaults())
43-
.csrf(csrf => csrf.disable()) // Stateless app using JWT
44-
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) // optional, disables session caching
45-
.authorizeExchange(authz =>
46-
authz.anyExchange().permitAll()
47-
)
48-
// .httpBasic().disable() // or leave enabled if using basic auth
49-
// .formLogin().disable()
51+
.csrf(csrf => csrf.disable())
52+
.authorizeExchange(_.anyExchange().permitAll())
53+
.addFilterAt(jwtAuthFilter, SecurityWebFiltersOrder.AUTHENTICATION)
54+
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) // stateless auth
5055
.build()
5156
}
57+
58+
@Bean
59+
def jwtAuthFilter(
60+
jwtAuthManager: JwtAuthManager
61+
): AuthenticationWebFilter = {
62+
val filter = new AuthenticationWebFilter(jwtAuthManager)
63+
filter.setServerAuthenticationConverter(new JwtServerAuthConverter)
64+
filter.setSecurityContextRepository(NoOpServerSecurityContextRepository.getInstance())
65+
filter
66+
}
67+
}
68+
69+
@Configuration(proxyBeanMethods = false)
70+
class CatsEffectConfig {
71+
72+
@Bean
73+
def catsEffectIORuntime(): IORuntime = {
74+
cats.effect.unsafe.implicits.global
75+
}
5276
}
5377

5478
@Configuration
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package com.example.scalaspringexperiment.auth
2+
3+
import cats.data.EitherT
4+
import cats.effect.IO
5+
import cats.effect.unsafe.IORuntime
6+
import com.example.scalaspringexperiment.auth.JwtAuthManager.{AuthError, InvalidCredentials, LoginSuccess, ROLE_USER, RegisterSuccess, UserExists, UserNotFound}
7+
import com.example.scalaspringexperiment.entity.{Person, RegisteredUser}
8+
import com.example.scalaspringexperiment.service.{PersonService, RegisteredUserService}
9+
import org.springframework.context.annotation.Lazy
10+
import org.springframework.security.authentication.{ReactiveAuthenticationManager, UsernamePasswordAuthenticationToken}
11+
import org.springframework.security.core.authority.SimpleGrantedAuthority
12+
import org.springframework.security.core.{Authentication, GrantedAuthority}
13+
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
14+
import org.springframework.stereotype.Component
15+
import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim}
16+
import reactor.core.publisher.Mono
17+
18+
import java.security.SecureRandom
19+
import java.time.Instant
20+
import scala.jdk.CollectionConverters.*
21+
22+
object JwtAuthManager {
23+
val ROLE_USER = "ROLE_USER"
24+
val ROLE_ADMIN = "ROLE_ADMIN"
25+
sealed trait AuthError {
26+
val message: String
27+
}
28+
29+
case class InvalidCredentials(
30+
message: String
31+
) extends AuthError
32+
33+
case class UserNotFound(
34+
message: String
35+
) extends AuthError
36+
37+
case class UserExists(
38+
message: String
39+
) extends AuthError
40+
41+
case class LoginSuccess(
42+
registeredUser: RegisteredUser,
43+
person: Person,
44+
jwtToken: String
45+
)
46+
47+
case class RegisterSuccess(
48+
registeredUser: RegisteredUser,
49+
person: Person,
50+
jwtToken: String
51+
)
52+
}
53+
54+
@Component
55+
class JwtAuthManager(
56+
@Lazy personService: PersonService,
57+
@Lazy registeredUserService: RegisteredUserService,
58+
runtime: IORuntime,
59+
) extends ReactiveAuthenticationManager {
60+
61+
private val hasher = new BCryptPasswordEncoder(10, new SecureRandom())
62+
63+
given theRuntime: IORuntime = runtime
64+
65+
// TODO: this is very bad:
66+
private val secretKey: String = "secretKey"
67+
private val algo = JwtAlgorithm.HS256
68+
69+
override def authenticate(authentication: Authentication): Mono[Authentication] = {
70+
println(s"[AUTH DEBUG] Called with credentials: ${authentication.getCredentials}")
71+
try {
72+
val token = authentication.getCredentials.toString
73+
val claim = JwtCirce.decode(token, secretKey, Seq(algo)).get
74+
val authorities = Seq(new SimpleGrantedAuthority(ROLE_USER))
75+
val auth = new UsernamePasswordAuthenticationToken(
76+
claim.subject.get,
77+
null,
78+
authorities.asJava
79+
)
80+
Mono.just(auth)
81+
} catch {
82+
case e: Exception => Mono.empty()
83+
}
84+
}
85+
86+
87+
def generateTokenForRegisteredUser(
88+
user: RegisteredUser,
89+
expiration: Instant
90+
): IO[String] = IO {
91+
val claim = JwtClaim(
92+
subject = Some(user.id.toString),
93+
expiration = Some(expiration.getEpochSecond),
94+
issuedAt = Some(Instant.now.getEpochSecond)
95+
)
96+
JwtCirce.encode(claim, secretKey, algo)
97+
}
98+
99+
def validatePassword(
100+
password: String,
101+
passwordHash: String
102+
): IO[Boolean] = {
103+
IO {
104+
hasher.matches(password, passwordHash)
105+
}.handleError { ex =>
106+
false
107+
}
108+
}
109+
110+
def login(
111+
email: String,
112+
password: String
113+
): IO[Either[AuthError, LoginSuccess]] = {
114+
(for {
115+
registeredUser <- EitherT(registeredUserService.findByEmail(email).map {
116+
case Some(user) => Right(user)
117+
case None => Left(UserNotFound("User not found"))
118+
})
119+
isValidPassword <- EitherT(validatePassword(password, registeredUser.passwordHash).map {
120+
case true => Right(true)
121+
case false => Left(InvalidCredentials("Invalid credentials"))
122+
})
123+
person <- EitherT(personService.findById(registeredUser.personId).map {
124+
case Some(p) => Right(p)
125+
case None => Left(UserNotFound("Person not found"))
126+
})
127+
jwtToken <- EitherT.liftF[IO, AuthError, String](generateTokenForRegisteredUser(registeredUser, Instant.now.plusSeconds(3600 * 30)))
128+
} yield LoginSuccess(
129+
registeredUser = registeredUser,
130+
person = person,
131+
jwtToken = jwtToken
132+
)).value
133+
}
134+
135+
def register(
136+
name: String,
137+
age: Int,
138+
email: String,
139+
password: String
140+
): IO[Either[AuthError, RegisterSuccess]] = {
141+
(for {
142+
existingUser <- EitherT(registeredUserService.findByEmail(email).map {
143+
case Some(_) => Left(UserExists("User already exists"))
144+
case None => Right(())
145+
})
146+
person <- EitherT.liftF(personService.insert(Person(
147+
name = name,
148+
age = age
149+
)))
150+
passwordHash <- EitherT.liftF(IO(hasher.encode(password)))
151+
registeredUser <- EitherT.liftF(registeredUserService.insert(RegisteredUser(
152+
email = email,
153+
passwordHash = passwordHash,
154+
personId = person.id,
155+
roles = List() // TODO
156+
)))
157+
jwtToken <- EitherT.liftF(generateTokenForRegisteredUser(registeredUser, Instant.now.plusSeconds(3600 * 30))) // 30 days
158+
} yield RegisterSuccess(
159+
registeredUser = registeredUser,
160+
person = person,
161+
jwtToken = jwtToken
162+
)).value
163+
}
164+
}

0 commit comments

Comments
 (0)