From c5841402f3863f0276083c5a8d7ebff07318590c Mon Sep 17 00:00:00 2001 From: Rainnny7 Date: Fri, 20 Sep 2024 02:42:11 -0400 Subject: [PATCH] /user/devices route --- pom.xml | 6 ++ .../api/controller/v1/UserController.java | 14 +++- .../api/model/user/device/BrowserType.java | 11 +++ .../api/model/user/device/Device.java | 74 +++++++++++++++++++ .../api/model/user/device/DeviceType.java | 10 +++ .../api/model/user/session/Session.java | 2 +- .../pulseapp/api/service/CaptchaService.java | 6 +- .../cc/pulseapp/api/service/UserService.java | 47 ++++++++++-- 8 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 src/main/java/cc/pulseapp/api/model/user/device/BrowserType.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/device/Device.java create mode 100644 src/main/java/cc/pulseapp/api/model/user/device/DeviceType.java diff --git a/pom.xml b/pom.xml index 81ceb37..02c98d8 100644 --- a/pom.xml +++ b/pom.xml @@ -104,6 +104,12 @@ 1.0 compile + + nl.basjes.parse.useragent + yauaa + 7.28.1 + compile + diff --git a/src/main/java/cc/pulseapp/api/controller/v1/UserController.java b/src/main/java/cc/pulseapp/api/controller/v1/UserController.java index f6c3f94..3de2c82 100644 --- a/src/main/java/cc/pulseapp/api/controller/v1/UserController.java +++ b/src/main/java/cc/pulseapp/api/controller/v1/UserController.java @@ -3,6 +3,7 @@ package cc.pulseapp.api.controller.v1; import cc.pulseapp.api.exception.impl.BadRequestException; import cc.pulseapp.api.model.user.User; import cc.pulseapp.api.model.user.UserDTO; +import cc.pulseapp.api.model.user.device.Device; import cc.pulseapp.api.model.user.input.CompleteOnboardingInput; import cc.pulseapp.api.model.user.input.DisableTFAInput; import cc.pulseapp.api.model.user.input.EnableTFAInput; @@ -99,7 +100,7 @@ public final class UserController { } /** - * A POST endpoint to disable TFA for a useer. + * A POST endpoint to disable TFA for a user. * * @param input the input to process * @return the disabled response @@ -111,6 +112,17 @@ public final class UserController { return ResponseEntity.ok(Map.of("success", true)); } + /** + * A GET endpoint to get the + * devices logged into the user. + * + * @return the devices + */ + @GetMapping("/devices") @ResponseBody @NonNull + public ResponseEntity> getDevices() { + return ResponseEntity.ok(userService.getDevices()); + } + /** * A POST endpoint to logout the user. * diff --git a/src/main/java/cc/pulseapp/api/model/user/device/BrowserType.java b/src/main/java/cc/pulseapp/api/model/user/device/BrowserType.java new file mode 100644 index 0000000..d92fa1b --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/device/BrowserType.java @@ -0,0 +1,11 @@ +package cc.pulseapp.api.model.user.device; + +/** + * The type of browser used + * by a {@link Device}. + * + * @author Braydon + */ +public enum BrowserType { + FIREFOX, EDGE, CHROME, SAFARI, SAMSUNGBROWSER, UNKNOWN +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/device/Device.java b/src/main/java/cc/pulseapp/api/model/user/device/Device.java new file mode 100644 index 0000000..7a19aad --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/device/Device.java @@ -0,0 +1,74 @@ +package cc.pulseapp.api.model.user.device; + +import cc.pulseapp.api.model.user.User; +import cc.pulseapp.api.model.user.session.Session; +import cc.pulseapp.api.model.user.session.SessionLocation; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.util.Date; + +/** + * A device logged into a + * {@link User}'s account. + * + * @author Braydon + */ +@AllArgsConstructor @Getter @ToString +public final class Device { + /** + * The type of this device. + */ + @NonNull private final DeviceType type; + + /** + * The browser type of this device. + */ + @NonNull private final BrowserType browserType; + + /** + * The IP address of this device. + */ + @NonNull private final String ip; + + /** + * The location of this device, if known. + */ + private final String location; + + /** + * The user agent of this device. + */ + @NonNull private final String userAgent; + + /** + * The session snowflake associated with this device. + */ + private final long sessionSnowflake; + + /** + * The date this device first logged into the account. + */ + private final Date firstLogin; + + /** + * Construct a device from a session. + * + * @param session the session + * @param deviceType the device type + * @param browserType the device browser type + * @param firstLogin the sessions first login time + * @return the constructed device + */ + @NonNull + public static Device fromSession(@NonNull Session session, @NonNull DeviceType deviceType, + @NonNull BrowserType browserType, @NonNull Date firstLogin) { + SessionLocation rawLocation = session.getLocation(); + String location = rawLocation.getCountry() == null ? null + : rawLocation.getCity() + ", " + rawLocation.getRegion() + ", " + rawLocation.getCountry(); + return new Device(deviceType, browserType, rawLocation.getIp(), location, + rawLocation.getUserAgent(), session.getSnowflake(), firstLogin); + } +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/device/DeviceType.java b/src/main/java/cc/pulseapp/api/model/user/device/DeviceType.java new file mode 100644 index 0000000..2919cde --- /dev/null +++ b/src/main/java/cc/pulseapp/api/model/user/device/DeviceType.java @@ -0,0 +1,10 @@ +package cc.pulseapp.api.model.user.device; + +/** + * The type of a {@link Device}. + * + * @author Braydon + */ +public enum DeviceType { + DESKTOP, TABLET, PHONE, UNKNOWN +} \ No newline at end of file diff --git a/src/main/java/cc/pulseapp/api/model/user/session/Session.java b/src/main/java/cc/pulseapp/api/model/user/session/Session.java index b8d1097..3ad8fc2 100644 --- a/src/main/java/cc/pulseapp/api/model/user/session/Session.java +++ b/src/main/java/cc/pulseapp/api/model/user/session/Session.java @@ -21,7 +21,7 @@ public final class Session { /** * The snowflake of this session. */ - @EqualsAndHashCode.Include @Id @JsonIgnore private final long snowflake; + @EqualsAndHashCode.Include @Id private final long snowflake; /** * The snowflake of the user this session is for. diff --git a/src/main/java/cc/pulseapp/api/service/CaptchaService.java b/src/main/java/cc/pulseapp/api/service/CaptchaService.java index 5b7e1f9..299dbef 100644 --- a/src/main/java/cc/pulseapp/api/service/CaptchaService.java +++ b/src/main/java/cc/pulseapp/api/service/CaptchaService.java @@ -1,5 +1,6 @@ package cc.pulseapp.api.service; +import cc.pulseapp.api.common.EnvironmentUtils; import cc.pulseapp.api.exception.impl.BadRequestException; import cc.pulseapp.api.model.IGenericResponse; import com.google.gson.JsonObject; @@ -26,8 +27,6 @@ public final class CaptchaService { * @throws BadRequestException if the response is invalid */ public void validateCaptcha(@NonNull String captchaResponse) throws BadRequestException { - System.out.println("captchaResponse = " + captchaResponse); - JsonObject body = new JsonObject(); body.addProperty("secret", secretKey); body.addProperty("response", captchaResponse); @@ -35,8 +34,7 @@ public final class CaptchaService { .header(HttpHeaders.CONTENT_TYPE, "application/json") .body(body) .asJson(); - System.out.println("response = " + response.getBody().toPrettyString()); - if (/*EnvironmentUtils.isProduction() && */!response.getBody().getObject().getBoolean("success")) { + if (EnvironmentUtils.isProduction() && !response.getBody().getObject().getBoolean("success")) { throw new BadRequestException(Error.CAPTCHA_INVALID); } } diff --git a/src/main/java/cc/pulseapp/api/service/UserService.java b/src/main/java/cc/pulseapp/api/service/UserService.java index e5899fc..cd1b385 100644 --- a/src/main/java/cc/pulseapp/api/service/UserService.java +++ b/src/main/java/cc/pulseapp/api/service/UserService.java @@ -10,6 +10,9 @@ import cc.pulseapp.api.model.user.TFAProfile; import cc.pulseapp.api.model.user.User; import cc.pulseapp.api.model.user.UserDTO; import cc.pulseapp.api.model.user.UserFlag; +import cc.pulseapp.api.model.user.device.BrowserType; +import cc.pulseapp.api.model.user.device.Device; +import cc.pulseapp.api.model.user.device.DeviceType; import cc.pulseapp.api.model.user.input.CompleteOnboardingInput; import cc.pulseapp.api.model.user.input.DisableTFAInput; import cc.pulseapp.api.model.user.input.EnableTFAInput; @@ -21,6 +24,9 @@ import cc.pulseapp.api.repository.UserRepository; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import lombok.NonNull; +import nl.basjes.parse.useragent.UserAgent; +import nl.basjes.parse.useragent.UserAgentAnalyzer; +import org.apache.commons.lang3.EnumUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -35,6 +41,12 @@ import java.util.concurrent.TimeUnit; */ @Service public final class UserService { + private static final UserAgentAnalyzer userAgentAnalyzer = UserAgentAnalyzer + .newBuilder() + .useJava8CompatibleCaching() + .withCache(10000) + .build(); + /** * The auth service to use. */ @@ -60,11 +72,6 @@ public final class UserService { */ @NonNull private final TFAService tfaService; - /** - * The captcha service to use. - */ - @NonNull private final CaptchaService captchaService; - /** * The user repository to use. */ @@ -89,14 +96,13 @@ public final class UserService { @Autowired public UserService(@NonNull AuthService authService, @NonNull SnowflakeService snowflakeService, @NonNull OrganizationService orgService, @NonNull StatusPageService statusPageService, - @NonNull TFAService tfaService, @NonNull CaptchaService captchaService, - @NonNull UserRepository userRepository, @NonNull SessionRepository sessionRepository) { + @NonNull TFAService tfaService, @NonNull UserRepository userRepository, + @NonNull SessionRepository sessionRepository) { this.authService = authService; this.snowflakeService = snowflakeService; this.orgService = orgService; this.statusPageService = statusPageService; this.tfaService = tfaService; - this.captchaService = captchaService; this.userRepository = userRepository; this.sessionRepository = sessionRepository; } @@ -245,6 +251,31 @@ public final class UserService { userRepository.save(user); } + /** + * Get the devices logged into + * the authenticated user. + * + * @return the devices + */ + @NonNull + public List getDevices() { + List devices = new ArrayList<>(); + User user = authService.getAuthenticatedUser(); + for (Session session : sessionRepository.findAllByUserSnowflake(user.getSnowflake())) { + UserAgent.ImmutableUserAgent userAgent = userAgentAnalyzer.parse(session.getLocation().getUserAgent()); + DeviceType deviceType = EnumUtils.getEnum(DeviceType.class, userAgent.get("DeviceClass").getValue().toUpperCase()); + BrowserType browserType = EnumUtils.getEnum(BrowserType.class, userAgent.get("AgentName").getValue().toUpperCase()); + if (deviceType == null) { + deviceType = DeviceType.UNKNOWN; + } + if (browserType == null) { + browserType = BrowserType.UNKNOWN; + } + devices.add(Device.fromSession(session, deviceType, browserType, new Date(snowflakeService.extractCreationTime(session.getSnowflake())))); + } + return devices; + } + /** * Logout the user. */