SessionController.java
/*
* BSD 2-Clause License
*
* Copyright (c) 2022, [Aleksandra Serba, Marcin Czerniak, Bartosz Wawrzyniak, Adrian Antkowiak]
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package dev.vernite.vernite.user.session;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import jakarta.validation.constraints.NotNull;
import dev.vernite.vernite.user.User;
import dev.vernite.vernite.user.UserSession;
import dev.vernite.vernite.user.UserSessionRepository;
import dev.vernite.vernite.user.auth.AuthController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import reactor.netty.http.client.HttpClient;
@RestController
@RequestMapping("/session")
public class SessionController {
private static final Logger L = Logger.getLogger("GeoIP");
private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor();
private static final WebClient CLIENT = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(HttpClient.create().responseTimeout(Duration.ofSeconds(1))))
.build();
private static final Map<String, GeoIP> geoip = new ConcurrentHashMap<>();
@Autowired
private UserSessionRepository userSessionRepository;
@Value("${maxmindPassword}")
private String maxmindPassword;
@Operation(summary = "List all active sessions", description = "This method returns array of all sessions. Result can be empty array.")
@ApiResponse(responseCode = "200", description = "List of all active sessions. Can be empty.", content = {
@Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = UserSession.class)))
})
@GetMapping
public Future<List<UserSession>> all(@NotNull @Parameter(hidden = true) User loggedUser,
@Parameter(hidden = true) @CookieValue(AuthController.COOKIE_NAME) String session) {
List<UserSession> sessions = userSessionRepository.findByUser(loggedUser);
HashSet<String> ips = new HashSet<>();
long t = System.currentTimeMillis();
for (UserSession s : sessions) {
s.setCurrent(s.getSession().equals(session));
GeoIP g = geoip.get(s.getIp());
if (g != null && t - g.getCache() > TimeUnit.DAYS.toMillis(1)) {
geoip.remove(s.getIp());
g = null;
}
if (g == null) {
ips.add(s.getIp());
} else {
s.setGeoip(g);
}
}
CountDownLatch countDownLatch = new CountDownLatch(ips.size());
for (String ip : ips) {
CLIENT.get()
.uri("https://geolite.info/geoip/v2.1/city/" + ip)
.header("Authorization", "Basic " + maxmindPassword)
.retrieve().bodyToMono(MaxmindResponse.class)
.subscribe(n -> {
GeoIP geoIP = new GeoIP();
geoIP.setCache(t);
if (n.getCity() != null && n.getCity().getNames().containsKey("en")) {
geoIP.setCity(n.getCity().getNames().get("en"));
}
if (n.getCountry() != null && n.getCountry().getNames().containsKey("en")) {
geoIP.setCountry(n.getCountry().getNames().get("en"));
}
geoip.put(ip, geoIP);
for (UserSession s : sessions) {
if (s.getIp().equals(ip)) {
s.setGeoip(geoIP);
}
}
countDownLatch.countDown();
}, (e) -> {
L.warning("Failed: " + e);
countDownLatch.countDown();
});
}
CompletableFuture<List<UserSession>> f = new CompletableFuture<>();
EXECUTOR_SERVICE.execute(() -> {
try {
countDownLatch.await(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
}
f.complete(sessions);
});
return f;
}
@Operation(summary = "Revoke session", description = "This method is used to revoke session. On success does not return anything.")
@ApiResponse(responseCode = "200", description = "Session revoked")
@ApiResponse(responseCode = "403", description = "Cannot revoke current (active) session or another user")
@DeleteMapping("/{id}")
public void delete(@NotNull @Parameter(hidden = true) User loggedUser,
@Parameter(hidden = true) @CookieValue(AuthController.COOKIE_NAME) String session, @PathVariable long id) {
UserSession sess = this.userSessionRepository.findById(id).orElse(null);
if (sess == null) {
return;
}
if (sess.getSession().equals(session)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "cannot revoke current session, click logout");
}
if (sess.getUser().getId() != loggedUser.getId()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "cannot revoke session with given ID");
}
this.userSessionRepository.delete(sess);
}
}