For a game to be real fun, there has to be a competition. When I decided to reactivate my lines game, there was need for Highscore backend. So I developed one with spring and docker.
Of course, there are services provided by google like firebase and whatever - but I like to keep privacy of my users, and control how data is saved. And I do not like idea to integrate yet another data collection framework into my app. And I have my own server anyway - so I decided to roll my own service. I should:
- Store reasonable amount of highscores (say - 1000)
- Game receives periodic updates (say every 10 minutes)
- Game app sends new game result from time to time
- Application shall be as simple as possible to develop and run
With those key features I decided to:
- store data as JSON file on FS (there is not much data, and no complicated queries are necesary - just some filtering - so no need to use database)
- develop kind of REST server with spring boot (to reduce boilerplate code)
- run in docker container (I used mod_jk and standalone java apps earlier, but it was a nightmare to run and setup, so some 100 MB extra storage for container is not an overhead)
- authenticate client via mutual TLS mutual authentication (pro: no need for any setup from user, applicaton has primary key I trust on server. Contra: private key can be extracted from app by someone using debugger, but there is no real protection against someone willing to debug java app)
Let’s see some code!
Interface
Rest service (simplified a little, just important parts, with comments):
public class HighscoreRest {
private static final Logger log = LoggerFactory.getLogger(HighscoreRest.class);
private static final Logger incoming = LoggerFactory.getLogger("incoming");
private final Highscore highscore;
private final Gson gson = new Gson();
// backend is instatiated by spring , not a point of interest now
public HighscoreRest(@Autowired Highscore highscore) {
this.highscore = highscore;
}
/**
* private endpoint
* store highscore entry and save it into backend
*
*
* @param entry
*/
(value = "/secured/store",
method = RequestMethod.POST)
public void store(@RequestBody Entry entry) {
log.info("store called");
// write out to logger
incoming.info(gson.toJson(entry));
// and store in separate thread as there is potential write to disk
new Thread(() -> highscore.doAdd(entry)).start();
}
/**
* public endpoint. crossOrigin will be necessary later, to call this endpoint from react application when I decide
* to write one
* provide list of scores
*
* @param since retrieve highscores stored since given time
* @return
*/
(value = "/public/scores", method = RequestMethod.GET)
()
public Collection<Entry> scores(@RequestParam(name = "since", defaultValue = "0") long since) {
log.info("scores read from " + since);
return highscore.scores(since);
}
(value = "/public/stats", method = RequestMethod.GET)
()
public Highscore.Stats stats(){
return highscore.getStats();
}
(value = "/public/status", method = RequestMethod.GET)
()
public Highscore.Status status(){
return highscore.getStatus();
}
public static void main(String[] args) {
SpringApplication.run(HighscoreRest.class, args);
}
}
So, for now everything simple - POST method to store incoming highscore entry (some magic happens in backend) and GET method for retrieval of stored highscores filtered by time.
Securing interface
Obviously, storing highscore shall be secured and allowed only for legitimate clients, and we do not care much about reading of highscores. SO we have to provide security configuration:
// this is security configuration, it will be picked up by spring boot and put to good use
(securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
public static final String CLIENT = "CLIENT";
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
// private endpoint, only trusted client allowed
.antMatchers("/secured/**").hasRole(CLIENT)
// public endpoint
.antMatchers("/public/**").permitAll()
// go avay everybody, nothing to see here
.anyRequest().denyAll()
.and()
// extract principal from certificates if there is any
.x509().subjectPrincipalRegex("CN=(.*?),")
// and grant roles
.userDetailsService(userDetailsService())
;
}
// role is granted here
public UserDetailsService userDetailsService() {
return username -> {
// shall match CN extracted by .x509().subjectPrincipalRegex("CN=(.*?),") from client certificate!
if (username.equals("lines_app")) {
log.info("authorising user lines_app");
return new User(username, "",
// CAVE AT: IT has to be prepended with ROLE_
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_CLIENT"));
} else {
throw new UsernameNotFoundException("User:" + username + " not found");
}
};
}
}
Here we define that somebody calling our endpoint must provide proper CN in their certificate (lines app ). Public methods can be called at will. And we also neeed to configure TLS for client authentication with proper cryptographic artifacts:
#default properties can be changed later
=8443
=crypto/lines_highscore_server.jks
=omited
=lines_highscore_server
=crypto/lines_truststore_server.jks
=omited
=want
Important part is that client authentication is optional (want) - this way secured part will need trusted client while unsecure part can be called by anybody.
And here come keystores and trustores.
Keystore for server (provide password, CN and other stuff does not matter for us here):
keytool -genkeypair -keyalg RSA -alias lines_highscore_server -keystore lines_highscore_server.jks
Keystore for client (also, provide password, but CN has do be set to “lines_app” - as seen in userDetailsService() before )
keytool -genkeypair -keyalg RSA -alias lines_highscore_client -keystore lines_highscore_client.jks
And export certificate from client keystore:
keytool -exportcert -file client.cer -alias lines_highscore_client -keystore lines_highscore_client.jks -storepass whatever -rfc
And trust it on server:
keytool -importcert -file client.cer -alias server -keystore lines_truststore_server.jks -storepass ehatever -noprompt
Now we are explicitely trusting client key on server side.
Building and deploying:
I use gradle build for this small app, nothing really interesting here:
plugins {
id 'org.springframework.boot' version '2.4.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
id 'maven-publish'
}
repositories {
mavenLocal()
maven {
url = uri('https://pribluda.de/m2')
}
maven {
url = uri('https://repo.maven.apache.org/maven2/')
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.google.code.gson:gson:2.8.6'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
}
group = 'de.pribluda.lines'
version = '0.1-SNAPSHOT'
description = 'highscore-server'
java.sourceCompatibility = JavaVersion.VERSION_1_8
publishing {
publications {
maven(MavenPublication) {
from(components.java)
}
}
}
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
}
End everything goes into docker container:
FROM gradle:latest AS build
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle build --no-daemon
FROM openjdk:8-jre-alpine
EXPOSE 8443
RUN addgroup -S highscore && adduser highscore -S highscore -G highscore
COPY --from=build /home/gradle/src/build/libs/*.jar highscore-service.jar
RUN mkdir /data && chown highscore:highscore /data
RUN mkdir /logs && chown highscore:highscore /logs
RUN mkdir /crypto && chown highscore:highscore /crypto
COPY crypto/lines_*_server.jks /crypto/
USER highscore:highscore
ENTRYPOINT ["java","-jar","/highscore-service.jar" , "--highscore.filename=/data/highscore.json" , "--spring.profiles.active=production,dev"]
Build this container
docker image build -t lines-highscore .
Shipt it on server import, and launch it:
sudo docker volume create lines-hs-data
sudo docker volume create lines-hs-logs
sudo docker run -d --restart unless-stopped -p 8443:8443 -v lines-hs-data:/data -v lines-hs-logs:/logs lines-highscore
And now you can see webservice working (you will have to trust muy certificate)
Do not forget to play the game
Lines game - it is still easy to get into highscore list.