Compare commits

...

18 Commits

11 changed files with 319 additions and 71 deletions

View File

@@ -7,7 +7,8 @@ FROM mvn as builder
WORKDIR /build WORKDIR /build
ADD . . ADD . .
RUN mvn package ARG KC_HOSTNAME
RUN mvn package -DKC_HOSTNAME=${KC_HOSTNAME}
FROM eclipse-temurin:21-jdk-alpine FROM eclipse-temurin:21-jdk-alpine

19
pom.xml
View File

@@ -37,7 +37,24 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<version>42.7.3</version> <version>42.7.3</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -13,6 +13,8 @@ import com.fasterxml.jackson.annotation.JsonProperty;
public class JodelPost { public class JodelPost {
// id of the post // id of the post
public Long id; public Long id;
// Parent id
public Optional<Long> parent = Optional.empty();
// id of the author in db // id of the author in db
private final Long authorID; private final Long authorID;
// anonymized authorID // anonymized authorID
@@ -25,31 +27,24 @@ public class JodelPost {
public Timestamp date; public Timestamp date;
// location if the post // location if the post
public Location location; public Location location;
// list of all comments for the post
public Vector<JodelPost> comments = new Vector<>();
// the own reaction (null = none, true = positive, false = negative) // the own reaction (null = none, true = positive, false = negative)
public Optional<Boolean> reaction; public Optional<Boolean> reaction;
// all other reactions // all other reactions
public Reactions reactions; public Reactions reactions;
// Parent id // list of all comments for the post
public Optional<Long> parent = Optional.empty(); public Vector<JodelPost> comments = new Vector<>();
// anonymize function to recursively anonymize the posts // anonymize function to recursively anonymize the posts
public void anonymize(Optional<Vector<Long>> idCache) { public void anonymize(Vector<Long> idCache) {
// check if this is the first post in this process
if (idCache.isEmpty()) {
// create a new Vector as cache
idCache = Optional.of(new Vector<>());
}
// get the anonymized id as index in cached authorIDs // get the anonymized id as index in cached authorIDs
int i = idCache.get().indexOf(this.authorID); int i = idCache.indexOf(this.authorID);
// if the index is -1 the authorID has not been cached jet // if the index is -1 the authorID has not been cached jet
if (i == -1) { if (i == -1) {
// set the current anonymousID as length of the cache (== next index) // set the current anonymousID as length of the cache (== next index)
this.anonymousID = (long) idCache.get().size(); this.anonymousID = (long) idCache.size();
// push the current authorID to to cache // push the current authorID to to cache
idCache.get().add(this.authorID); idCache.add(this.authorID);
} }
// the authorID has been anonymized once before, so we can get it from cache // the authorID has been anonymized once before, so we can get it from cache
else this.anonymousID = (long) i; else this.anonymousID = (long) i;
@@ -88,6 +83,7 @@ public class JodelPost {
this.content = rs.getString("content"); this.content = rs.getString("content");
this.date = rs.getTimestamp("postdate"); this.date = rs.getTimestamp("postdate");
this.location = new Location(rs.getFloat("longitude"), rs.getFloat("latitude")); this.location = new Location(rs.getFloat("longitude"), rs.getFloat("latitude"));
this.reaction = rs.getString("reaction") == null ? Optional.empty() : Optional.of(rs.getBoolean("reaction"));
this.reactions = new Reactions(rs.getLong("positive"), rs.getLong("negative")); this.reactions = new Reactions(rs.getLong("positive"), rs.getLong("negative"));
this.parent = Optional.of(rs.getLong("parent")); this.parent = Optional.of(rs.getLong("parent"));
} }
@@ -102,10 +98,8 @@ public class JodelPost {
@JsonProperty("content") String content, @JsonProperty("content") String content,
@JsonProperty("date") Timestamp date, @JsonProperty("date") Timestamp date,
@JsonProperty("location") Location location, @JsonProperty("location") Location location,
@JsonProperty("parent") Optional<Long> parent, @JsonProperty("parent") Optional<Long> parent) {
// TODO: getter from Keycloak this.authorID = User.getID(); // TODO: getter from Keycloak
@JsonProperty("authorID") long author) {
this.authorID = author; // TODO: getter from Keycloak
this.title = title; this.title = title;
this.content = content; this.content = content;
this.date = date; this.date = date;

View File

@@ -4,8 +4,8 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
public class Location { public class Location {
public float longitude;
public float latitude; public float latitude;
public float longitude;
@JsonCreator @JsonCreator
public Location(@JsonProperty("longitude") float longitude, @JsonProperty("latitude") float latitude ) { public Location(@JsonProperty("longitude") float longitude, @JsonProperty("latitude") float latitude ) {

View File

@@ -13,11 +13,14 @@ import java.util.Vector;
@CrossOrigin(origins = "*", allowedHeaders = "*", maxAge = 3600) @CrossOrigin(origins = "*", allowedHeaders = "*", maxAge = 3600)
public class Routes { public class Routes {
@GetMapping("/posts/{longitude}/{latitude}") @GetMapping("/posts/{latitude}/{longitude}")
public Vector<JodelPost> getPostsByLocation(@PathVariable("longitude") float longitude, @PathVariable("latitude") float latitude) { public Vector<JodelPost> getPostsByLocation(@PathVariable("longitude") float longitude, @PathVariable("latitude") float latitude) {
// list of all posts (not comments) in range // list of all posts (not comments) in range
Vector<JodelPost> posts = new Vector<>(); Vector<JodelPost> posts = new Vector<>();
// get UserID
Long userID = User.getID();
// DB connection and statement // DB connection and statement
Connection c; Connection c;
PreparedStatement stmt; PreparedStatement stmt;
@@ -44,8 +47,8 @@ public class Routes {
posts.title, posts.title,
posts.content, posts.content,
posts.postdate, posts.postdate,
posts.postlocation[0] AS longitude, posts.postlocation[0] AS latitude,
posts.postlocation[1] AS latitude, posts.postlocation[1] AS longitude,
(SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = TRUE) AS positive, (SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = TRUE) AS positive,
(SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = FALSE) AS negative (SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = FALSE) AS negative
FROM FROM
@@ -73,10 +76,11 @@ public class Routes {
(SELECT * FROM comments inner join posts ON comments.child = posts.id) com (SELECT * FROM comments inner join posts ON comments.child = posts.id) com
inner join targets ON targets.id = com.parent inner join targets ON targets.id = com.parent
) )
SELECT * FROM targets;"""); SELECT * FROM targets LEFT JOIN (SELECT post, positive as reaction FROM reactions WHERE userid = (?)) ON post = id;""");
stmt.setObject(1, longitude); stmt.setObject(1, latitude);
stmt.setObject(2, latitude); stmt.setObject(2, longitude);
stmt.setObject(3, userID);
// query recursively for posts inside a 10km radius // query recursively for posts inside a 10km radius
ResultSet rs = stmt.executeQuery(); ResultSet rs = stmt.executeQuery();
@@ -115,7 +119,12 @@ public class Routes {
} }
// calculate anonymous IDs for the posts // calculate anonymous IDs for the posts
posts.forEach(post -> post.anonymize(Optional.empty())); Vector<Long> anonymousIDs = new Vector<Long>();
anonymousIDs.add(userID);
posts.forEach(post -> {
post.anonymize(anonymousIDs);
});
// return the posts // return the posts
return posts; return posts;
} }
@@ -125,6 +134,9 @@ public class Routes {
// list of all posts (not comments) in range // list of all posts (not comments) in range
Optional<JodelPost> root_post = Optional.empty(); Optional<JodelPost> root_post = Optional.empty();
// get UserID
Long userID = User.getID();
// DB connection and statement // DB connection and statement
Connection c; Connection c;
PreparedStatement stmt; PreparedStatement stmt;
@@ -151,8 +163,8 @@ public class Routes {
posts.title, posts.title,
posts.content, posts.content,
posts.postdate, posts.postdate,
posts.postlocation[0] AS longitude, posts.postlocation[0] AS latitude,
posts.postlocation[1] AS latitude, posts.postlocation[1] AS longitude,
(SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = TRUE) AS positive, (SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = TRUE) AS positive,
(SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = FALSE) AS negative (SELECT count(*) FROM reactions WHERE reactions.post = posts.id AND reactions.positive = FALSE) AS negative
FROM FROM
@@ -177,9 +189,10 @@ public class Routes {
(SELECT * FROM comments inner join posts ON comments.child = posts.id) com (SELECT * FROM comments inner join posts ON comments.child = posts.id) com
inner join targets ON targets.id = com.parent inner join targets ON targets.id = com.parent
) )
SELECT * FROM targets;"""); SELECT * FROM targets LEFT JOIN (SELECT post, positive as reaction FROM reactions WHERE userid = (?)) ON post = id;""");
stmt.setObject(1, id); stmt.setObject(1, id);
stmt.setObject(2, userID);
// query recursively for posts inside a 10km radius // query recursively for posts inside a 10km radius
ResultSet rs = stmt.executeQuery(); ResultSet rs = stmt.executeQuery();
@@ -218,7 +231,9 @@ public class Routes {
} }
// calculate anonymous IDs for the post // calculate anonymous IDs for the post
root_post.ifPresent(root -> root.anonymize(Optional.empty())); Vector<Long> anonymousIDs = new Vector<Long>();
anonymousIDs.add(userID);
root_post.ifPresent(root -> root.anonymize(anonymousIDs));
// return the posts // return the posts
if (root_post.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No post found"); if (root_post.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No post found");
@@ -251,8 +266,8 @@ public class Routes {
stmt.setObject(2, post.title); stmt.setObject(2, post.title);
stmt.setObject(3, post.content); stmt.setObject(3, post.content);
stmt.setObject(4, post.date); stmt.setObject(4, post.date);
stmt.setObject(5, post.location.longitude); stmt.setObject(5, post.location.latitude);
stmt.setObject(6, post.location.latitude); stmt.setObject(6, post.location.longitude);
// insert post and get its id // insert post and get its id
ResultSet rs = stmt.executeQuery(); ResultSet rs = stmt.executeQuery();
@@ -294,11 +309,73 @@ public class Routes {
post.reaction = Optional.empty(); post.reaction = Optional.empty();
// anonymize code // anonymize code
post.anonymize(Optional.empty()); Vector<Long> anonymousIDs = new Vector<Long>();
anonymousIDs.add(0L);
post.anonymize(anonymousIDs);
return post; return post;
} }
@PatchMapping("/post/{id}")
public void reactToPost(@PathVariable long id, @RequestBody JodelPost post) {
System.out.println(post.reaction);
long userID = User.getID();
// DB connection and statement
Connection c;
PreparedStatement stmt;
// try to get data from db
try {
// check for the driver
Class.forName("org.postgresql.Driver");
// get the connection with credentials from env variables
c = DriverManager
.getConnection("jdbc:postgresql://"+
System.getenv("POSTGRES_IP")+"/"+System.getenv("POSTGRES_DB"),
System.getenv("POSTGRES_USER"), System.getenv("POSTGRES_PASSWORD"));
// disable auto commits
c.setAutoCommit(false);
// create a new statement
stmt = c.prepareStatement("UPDATE Reactions SET positive = (?) WHERE post = (?) AND userid = (?) RETURNING id, positive");
stmt.setObject(1, post.reaction.orElse(null));
stmt.setObject(2, id);
stmt.setObject(3, userID);
// insert post and get its id
ResultSet rs = stmt.executeQuery();
// check if there is a parent
if (!rs.next()) {
// create a new statement
stmt = c.prepareStatement("INSERT INTO Reactions(userid, post, positive) VALUES ((?), (?), (?))");
// fill statement
stmt.setObject(1, userID);
stmt.setObject(2, id);
stmt.setObject(3, post.reaction.orElse(null));
// execute statement
stmt.execute();
}
// commit the changes
c.commit();
// close all connections to db
stmt.close();
c.close();
}
// else log the error
catch ( Exception e ) {
System.err.println( e.getClass().getName()+": "+ e.getMessage() );
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Database is offline");
}
}
@DeleteMapping("/post/{id}") @DeleteMapping("/post/{id}")
public void deletePost(@PathVariable long id) { public void deletePost(@PathVariable long id) {
// DB connection and statement // DB connection and statement
@@ -318,9 +395,10 @@ public class Routes {
c.setAutoCommit(false); c.setAutoCommit(false);
// create a new statement // create a new statement
stmt = c.prepareStatement("UPDATE Posts SET deleted = now() WHERE id = ? AND deleted IS NULL"); stmt = c.prepareStatement("UPDATE Posts SET deleted = now() WHERE id = ? AND author = (?) AND deleted IS NULL");
stmt.setObject(1, id); stmt.setObject(1, id);
stmt.setObject(2, User.getID());
// insert delete time // insert delete time
stmt.execute(); stmt.execute();

View File

@@ -0,0 +1,77 @@
package de.anxietyprime.swajodel;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class User {
static public String getUUID () {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
static public Long getID() {
String uuid = SecurityContextHolder.getContext().getAuthentication().getName();
long id;
// DB connection and statement
Connection c;
PreparedStatement stmt;
// try to get data from db
try {
// check for the driver
Class.forName("org.postgresql.Driver");
// get the connection with credentials from env variables
c = DriverManager
.getConnection("jdbc:postgresql://"+
System.getenv("POSTGRES_IP")+"/"+System.getenv("POSTGRES_DB"),
System.getenv("POSTGRES_USER"), System.getenv("POSTGRES_PASSWORD"));
// disable auto commits
c.setAutoCommit(false);
// create a new statement
stmt = c.prepareStatement("SELECT id FROM users WHERE username = (?)");
stmt.setObject(1, uuid);
// insert post and get its id
ResultSet rs = stmt.executeQuery();
// check if there is a parent
if (!rs.next()) {
// create a new statement
stmt = c.prepareStatement("INSERT INTO users(username, password) VALUES (?, 'unused') RETURNING id");
// fill statement
stmt.setObject(1, uuid);
// execute statement
rs = stmt.executeQuery();
// commit the changes
c.commit();
rs.next();
}
id = rs.getLong("id");
// close all connections to db
stmt.close();
c.close();
}
// else log the error
catch ( Exception e ) {
System.err.println( e.getClass().getName()+": "+ e.getMessage() );
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Database is offline");
}
return id;
}
}

View File

@@ -0,0 +1,60 @@
package de.anxietyprime.swajodel.security;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Component
public class JwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
private final JwtConverterProperties properties;
public JwtConverter(JwtConverterProperties properties) {
this.properties = properties;
}
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = Stream.concat(
jwtGrantedAuthoritiesConverter.convert(jwt).stream(),
extractResourceRoles(jwt).stream()).collect(Collectors.toSet());
return new JwtAuthenticationToken(jwt, authorities, getPrincipalClaimName(jwt));
}
private String getPrincipalClaimName(Jwt jwt) {
String claimName = JwtClaimNames.SUB;
if (properties.getPrincipalAttribute() != null) {
claimName = properties.getPrincipalAttribute();
}
return jwt.getClaim(claimName);
}
private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
Map<String, Object> resource;
Collection<String> resourceRoles;
if (resourceAccess == null
|| (resource = (Map<String, Object>) resourceAccess.get(properties.getResourceId())) == null
|| (resourceRoles = (Collection<String>) resource.get("roles")) == null) {
return Set.of();
}
return resourceRoles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
}
}

View File

@@ -0,0 +1,19 @@
package de.anxietyprime.swajodel.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
@Data
@Validated
@Configuration
@ConfigurationProperties(prefix = "jwt.auth.converter")
public class JwtConverterProperties {
private String resourceId;
private String principalAttribute;
}

View File

@@ -0,0 +1,33 @@
package de.anxietyprime.swajodel.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {
public static final String ADMIN = "admin";
public static final String USER = "user";
private final JwtConverter jwtConverter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authz) ->
authz.requestMatchers(HttpMethod.GET, "/messages/").permitAll()
.anyRequest().authenticated());
http.sessionManagement(sess -> sess.sessionCreationPolicy(
SessionCreationPolicy.STATELESS));
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
return http.build();
}
}

View File

@@ -1 +1,5 @@
spring.application.name=SWA-Jodel spring.application.name=SWA-Jodel
# Security Configuration
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://${KC_HOSTNAME}/realms/Jodel
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs

View File

@@ -3,44 +3,9 @@ package de.anxietyprime.swajodel;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import java.util.Optional; import java.util.Vector;
@SpringBootTest @SpringBootTest
class SwaJodelApplicationTests { class SwaJodelApplicationTests {
@Test
void anonymousTest() {
JodelPost post = new JodelPost(1000);
post.comments.add(new JodelPost(1001));
post.comments.add(new JodelPost(1002));
post.comments.get(0).comments.add(new JodelPost(1000));
post.comments.get(0).comments.add(new JodelPost(1001));
post.comments.get(0).comments.add(new JodelPost(1000));
post.comments.add(new JodelPost(1003));
post.comments.get(2).comments.add(new JodelPost(1001));
post.comments.get(2).comments.add(new JodelPost(1002));
post.comments.get(2).comments.add(new JodelPost(1003));
post.comments.get(2).comments.add(new JodelPost(1000));
post.comments.add(new JodelPost(1000));
post.comments.add(new JodelPost(1001));
post.comments.add(new JodelPost(1001));
post.anonymize(Optional.empty());
assert (post.anonymousID == 0);
assert (post.comments.get(0).anonymousID == 1);
assert (post.comments.get(1).anonymousID == 2);
assert (post.comments.get(2).anonymousID == 3);
assert (post.comments.get(3).anonymousID == 0);
assert (post.comments.get(4).anonymousID == 1);
assert (post.comments.get(5).anonymousID == 1);
assert (post.comments.get(0).comments.get(0).anonymousID == 0);
assert (post.comments.get(0).comments.get(1).anonymousID == 1);
assert (post.comments.get(0).comments.get(2).anonymousID == 0);
assert (post.comments.get(2).comments.get(0).anonymousID == 1);
assert (post.comments.get(2).comments.get(1).anonymousID == 2);
assert (post.comments.get(2).comments.get(2).anonymousID == 3);
assert (post.comments.get(2).comments.get(3).anonymousID == 0);
}
} }