From 3d1a237a3474e69d330eb1eef8fed3f1aef4a396 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 17 Feb 2026 00:33:54 +0100 Subject: [PATCH 1/7] use deviceId instead of ipAddress for devices --- .../java/org/bugzkit/api/auth/AuthTokens.java | 3 + .../api/auth/controller/AuthController.java | 89 ++++++------ .../jwt/redis/model/RefreshTokenStore.java | 2 +- .../RefreshTokenStoreRepository.java | 2 +- .../auth/jwt/service/RefreshTokenService.java | 6 +- .../service/impl/RefreshTokenServiceImpl.java | 13 +- .../bugzkit/api/auth/mapper/AuthMapper.java | 17 +++ .../org/bugzkit/api/auth/model/Device.java | 54 ++++++++ .../api/auth/oauth2/OAuth2SuccessHandler.java | 13 +- .../api/auth/payload/dto/AuthTokensDTO.java | 3 - .../api/auth/payload/dto/DeviceDTO.java | 10 ++ .../api/auth/repository/DeviceRepository.java | 16 +++ .../bugzkit/api/auth/service/AuthService.java | 8 +- .../api/auth/service/DeviceService.java | 16 +++ .../auth/service/impl/AuthServiceImpl.java | 33 +++-- .../auth/service/impl/DeviceServiceImpl.java | 82 +++++++++++ .../org/bugzkit/api/auth/util/AuthUtil.java | 8 +- .../api/shared/config/SecurityConfig.java | 4 +- .../api/shared/logger/CustomLogger.java | 8 +- .../user/service/impl/ProfileServiceImpl.java | 8 +- .../src/main/resources/static/openapi.yml | 69 ++++++++++ .../data/RefreshTokenStoreRepositoryIT.java | 10 +- .../auth/integration/AuthControllerIT.java | 47 ++++++- .../bugzkit/api/auth/unit/AuthMapperTest.java | 49 +++++++ .../integration/AccessingResourcesIT.java | 18 +++ .../api/shared/util/IntegrationTestUtil.java | 11 +- .../bugzkit/api/user/unit/UserMapperTest.java | 83 ++++++++++++ frontend/svelte-kit/messages/en.json | 12 +- frontend/svelte-kit/messages/sr.json | 12 +- frontend/svelte-kit/package.json | 4 + frontend/svelte-kit/pnpm-lock.yaml | 37 +++++ frontend/svelte-kit/src/hooks.server.ts | 9 +- .../svelte-kit/src/lib/models/auth/device.ts | 7 + .../svelte-kit/src/lib/server/apis/api.ts | 6 + .../svelte-kit/src/lib/server/utils/util.ts | 1 + .../svelte-kit/src/routes/+layout.server.ts | 4 +- .../src/routes/auth/sign-in/+page.server.ts | 1 + ...onal-information.svelte => account.svelte} | 50 ++++++- .../settings/(components)/devices.svelte | 128 ++++++++++++++++++ .../{security.svelte => password.svelte} | 53 +------- .../routes/profile/settings/+page.server.ts | 40 +++++- .../src/routes/profile/settings/+page.svelte | 31 +++-- .../src/routes/profile/settings/schema.ts | 4 + 43 files changed, 908 insertions(+), 173 deletions(-) create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/mapper/AuthMapper.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/model/Device.java delete mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/AuthTokensDTO.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/DeviceDTO.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/DeviceService.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java create mode 100644 backend/spring-boot/src/test/java/org/bugzkit/api/auth/unit/AuthMapperTest.java create mode 100644 backend/spring-boot/src/test/java/org/bugzkit/api/user/unit/UserMapperTest.java create mode 100644 frontend/svelte-kit/src/lib/models/auth/device.ts rename frontend/svelte-kit/src/routes/profile/settings/(components)/{personal-information.svelte => account.svelte} (51%) create mode 100644 frontend/svelte-kit/src/routes/profile/settings/(components)/devices.svelte rename frontend/svelte-kit/src/routes/profile/settings/(components)/{security.svelte => password.svelte} (60%) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java new file mode 100644 index 00000000..f261dcf5 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java @@ -0,0 +1,3 @@ +package org.bugzkit.api.auth; + +public record AuthTokens(String accessToken, String refreshToken, String deviceId) {} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java index f5d737d9..10f5fb94 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java @@ -2,6 +2,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import java.util.List; +import org.bugzkit.api.auth.AuthTokens; +import org.bugzkit.api.auth.payload.dto.DeviceDTO; import org.bugzkit.api.auth.payload.request.AuthTokensRequest; import org.bugzkit.api.auth.payload.request.ForgotPasswordRequest; import org.bugzkit.api.auth.payload.request.RegisterUserRequest; @@ -9,6 +12,7 @@ import org.bugzkit.api.auth.payload.request.VerificationEmailRequest; import org.bugzkit.api.auth.payload.request.VerifyEmailRequest; import org.bugzkit.api.auth.service.AuthService; +import org.bugzkit.api.auth.service.DeviceService; import org.bugzkit.api.auth.util.AuthUtil; import org.bugzkit.api.shared.constants.Path; import org.bugzkit.api.user.payload.dto.UserDTO; @@ -17,6 +21,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -26,6 +32,7 @@ @RequestMapping(Path.AUTH) public class AuthController { private final AuthService authService; + private final DeviceService deviceService; @Value("${domain.name}") private String domain; @@ -36,8 +43,9 @@ public class AuthController { @Value("${jwt.refresh-token.duration}") private int refreshTokenDuration; - public AuthController(AuthService authService) { + public AuthController(AuthService authService, DeviceService deviceService) { this.authService = authService; + this.deviceService = deviceService; } @PostMapping("/register") @@ -49,59 +57,47 @@ public ResponseEntity register( @PostMapping("/tokens") public ResponseEntity authenticate( @Valid @RequestBody AuthTokensRequest authTokensRequest, HttpServletRequest request) { - final var ipAddress = AuthUtil.getUserIpAddress(request); - final var authTokensDTO = authService.authenticate(authTokensRequest, ipAddress); - final var accessTokenCookie = - AuthUtil.createCookie( - "accessToken", authTokensDTO.accessToken(), domain, accessTokenDuration); - final var refreshTokenCookie = - AuthUtil.createCookie( - "refreshToken", authTokensDTO.refreshToken(), domain, refreshTokenDuration); - return ResponseEntity.noContent() - .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - .build(); + final var deviceId = AuthUtil.getOrCreateDeviceId(request); + final var userAgent = request.getHeader("User-Agent"); + final var authTokens = authService.authenticate(authTokensRequest, deviceId, userAgent); + return buildTokenResponse(authTokens); } @DeleteMapping("/tokens") public ResponseEntity deleteTokens(HttpServletRequest request) { final var accessToken = AuthUtil.getValueFromCookie("accessToken", request); - final var ipAddress = AuthUtil.getUserIpAddress(request); - authService.deleteTokens(accessToken, ipAddress); - final var accessTokenCookie = AuthUtil.createCookie("accessToken", "", domain, 0); - final var refreshTokenCookie = AuthUtil.createCookie("refreshToken", "", domain, 0); - return ResponseEntity.noContent() - .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - .build(); + final var deviceId = AuthUtil.getValueFromCookie("deviceId", request); + authService.deleteTokens(accessToken, deviceId); + final var authTokens = new AuthTokens("", "", ""); + return buildTokenResponse(authTokens); + } + + @GetMapping("/tokens/devices") + public ResponseEntity> findAllDevices(HttpServletRequest request) { + final var deviceId = AuthUtil.getValueFromCookie("deviceId", request); + return ResponseEntity.ok(deviceService.findAll(deviceId)); } @DeleteMapping("/tokens/devices") public ResponseEntity deleteTokensOnAllDevices() { authService.deleteTokensOnAllDevices(); - final var accessTokenCookie = AuthUtil.createCookie("accessToken", "", domain, 0); - final var refreshTokenCookie = AuthUtil.createCookie("refreshToken", "", domain, 0); - return ResponseEntity.noContent() - .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - .build(); + final var authTokens = new AuthTokens("", "", ""); + return buildTokenResponse(authTokens); + } + + @DeleteMapping("/tokens/devices/{deviceId}") + public ResponseEntity revokeDevice(@PathVariable String deviceId) { + deviceService.revoke(deviceId); + return ResponseEntity.noContent().build(); } @PostMapping("/tokens/refresh") public ResponseEntity refreshTokens(HttpServletRequest request) { final var refreshToken = AuthUtil.getValueFromCookie("refreshToken", request); - final var ipAddress = AuthUtil.getUserIpAddress(request); - final var authTokensDTO = authService.refreshTokens(refreshToken, ipAddress); - final var accessTokenCookie = - AuthUtil.createCookie( - "accessToken", authTokensDTO.accessToken(), domain, accessTokenDuration); - final var refreshTokenCookie = - AuthUtil.createCookie( - "refreshToken", authTokensDTO.refreshToken(), domain, refreshTokenDuration); - return ResponseEntity.noContent() - .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - .build(); + final var deviceId = AuthUtil.getOrCreateDeviceId(request); + final var userAgent = request.getHeader("User-Agent"); + final var authTokens = authService.refreshTokens(refreshToken, deviceId, userAgent); + return buildTokenResponse(authTokens); } @PostMapping("/password/forgot") @@ -131,4 +127,19 @@ public ResponseEntity verifyEmail( authService.verifyEmail(verifyEmailRequest); return ResponseEntity.noContent().build(); } + + private ResponseEntity buildTokenResponse(AuthTokens authTokens) { + final var accessTokenCookie = + AuthUtil.createCookie("accessToken", authTokens.accessToken(), domain, accessTokenDuration); + final var refreshTokenCookie = + AuthUtil.createCookie( + "refreshToken", authTokens.refreshToken(), domain, refreshTokenDuration); + final var deviceIdCookie = + AuthUtil.createCookie("deviceId", authTokens.deviceId(), domain, refreshTokenDuration); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + .header(HttpHeaders.SET_COOKIE, deviceIdCookie.toString()) + .build(); + } } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/model/RefreshTokenStore.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/model/RefreshTokenStore.java index 211f60a6..52307152 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/model/RefreshTokenStore.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/model/RefreshTokenStore.java @@ -21,7 +21,7 @@ public class RefreshTokenStore implements Serializable { @Indexed private Long userId; - @Indexed private String ipAddress; + @Indexed private String deviceId; @TimeToLive private long timeToLive; } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/repository/RefreshTokenStoreRepository.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/repository/RefreshTokenStoreRepository.java index 6344a91b..df5557cb 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/repository/RefreshTokenStoreRepository.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/redis/repository/RefreshTokenStoreRepository.java @@ -6,7 +6,7 @@ import org.springframework.data.repository.CrudRepository; public interface RefreshTokenStoreRepository extends CrudRepository { - Optional findByUserIdAndIpAddress(Long userId, String ipAddress); + Optional findByUserIdAndDeviceId(Long userId, String deviceId); List findAllByUserId(Long userId); } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java index 1715f251..b7f9189e 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java @@ -5,15 +5,15 @@ import org.bugzkit.api.user.payload.dto.RoleDTO; public interface RefreshTokenService { - String create(Long userId, Set roleDTOs, String ipAddress); + String create(Long userId, Set roleDTOs, String deviceId); void check(String token); - Optional findByUserIdAndIpAddress(Long userId, String ipAddress); + Optional findByUserIdAndDeviceId(Long userId, String deviceId); void delete(String token); - void deleteByUserIdAndIpAddress(Long userId, String ipAddress); + void deleteByUserIdAndDeviceId(Long userId, String deviceId); void deleteAllByUserId(Long userId); } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java index fa27db62..65c9791a 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java @@ -30,7 +30,7 @@ public RefreshTokenServiceImpl(RefreshTokenStoreRepository refreshTokenStoreRepo } @Override - public String create(Long userId, Set roleDTOs, String ipAddress) { + public String create(Long userId, Set roleDTOs, String deviceId) { final var token = JWT.create() .withIssuer(userId.toString()) @@ -39,8 +39,7 @@ public String create(Long userId, Set roleDTOs, String ipAddress) { .withIssuedAt(Instant.now()) .withExpiresAt(Instant.now().plusSeconds(tokenDuration)) .sign(JwtUtil.getAlgorithm(secret)); - refreshTokenStoreRepository.save( - new RefreshTokenStore(token, userId, ipAddress, tokenDuration)); + refreshTokenStoreRepository.save(new RefreshTokenStore(token, userId, deviceId, tokenDuration)); return token; } @@ -64,9 +63,9 @@ private void isInRefreshTokenStore(String token) { } @Override - public Optional findByUserIdAndIpAddress(Long userId, String ipAddress) { + public Optional findByUserIdAndDeviceId(Long userId, String deviceId) { return refreshTokenStoreRepository - .findByUserIdAndIpAddress(userId, ipAddress) + .findByUserIdAndDeviceId(userId, deviceId) .map(RefreshTokenStore::getRefreshToken); } @@ -76,9 +75,9 @@ public void delete(String token) { } @Override - public void deleteByUserIdAndIpAddress(Long userId, String ipAddress) { + public void deleteByUserIdAndDeviceId(Long userId, String deviceId) { refreshTokenStoreRepository - .findByUserIdAndIpAddress(userId, ipAddress) + .findByUserIdAndDeviceId(userId, deviceId) .ifPresent(refreshTokenStoreRepository::delete); } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/mapper/AuthMapper.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/mapper/AuthMapper.java new file mode 100644 index 00000000..759b43c3 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/mapper/AuthMapper.java @@ -0,0 +1,17 @@ +package org.bugzkit.api.auth.mapper; + +import org.bugzkit.api.auth.model.Device; +import org.bugzkit.api.auth.payload.dto.DeviceDTO; +import org.mapstruct.Context; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface AuthMapper { + AuthMapper INSTANCE = Mappers.getMapper(AuthMapper.class); + + @Mapping(target = "current", expression = "java(device.getDeviceId().equals(currentDeviceId))") + DeviceDTO deviceToDeviceDTO(Device device, @Context String currentDeviceId); +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/model/Device.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/model/Device.java new file mode 100644 index 00000000..813c27fe --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/model/Device.java @@ -0,0 +1,54 @@ +package org.bugzkit.api.auth.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.bugzkit.api.user.model.User; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table( + name = "devices", + indexes = { + @Index(name = "idx_device_user_id", columnList = "user_id"), + @Index(name = "idx_device_user_device", columnList = "user_id, deviceId", unique = true) + }) +public class Device { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String deviceId; + + private String userAgent; + + @Builder.Default + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Builder.Default + @Column(nullable = false) + private LocalDateTime lastActiveAt = LocalDateTime.now(); +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java index 57794f27..45a314d0 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import org.bugzkit.api.auth.jwt.service.AccessTokenService; import org.bugzkit.api.auth.jwt.service.RefreshTokenService; +import org.bugzkit.api.auth.service.DeviceService; import org.bugzkit.api.auth.util.AuthUtil; import org.bugzkit.api.shared.logger.CustomLogger; import org.bugzkit.api.user.payload.dto.RoleDTO; @@ -20,6 +21,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private final AccessTokenService accessTokenService; private final RefreshTokenService refreshTokenService; + private final DeviceService deviceService; private final CustomLogger customLogger; @Value("${domain.name}") @@ -37,9 +39,11 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { public OAuth2SuccessHandler( AccessTokenService accessTokenService, RefreshTokenService refreshTokenService, + DeviceService deviceService, CustomLogger customLogger) { this.accessTokenService = accessTokenService; this.refreshTokenService = refreshTokenService; + this.deviceService = deviceService; this.customLogger = customLogger; } @@ -52,15 +56,20 @@ public void onAuthenticationSuccess( userPrincipal.getAuthorities().stream() .map(authority -> new RoleDTO(authority.getAuthority())) .collect(Collectors.toSet()); - final var ipAddress = AuthUtil.getUserIpAddress(request); + final var deviceId = AuthUtil.getOrCreateDeviceId(request); final var accessToken = accessTokenService.create(userPrincipal.getId(), roleDTOs); - final var refreshToken = refreshTokenService.create(userPrincipal.getId(), roleDTOs, ipAddress); + final var refreshToken = refreshTokenService.create(userPrincipal.getId(), roleDTOs, deviceId); + final var userAgent = request.getHeader("User-Agent"); + deviceService.createOrUpdate(userPrincipal.getId(), deviceId, userAgent); final var accessTokenCookie = AuthUtil.createCookie("accessToken", accessToken, domain, accessTokenDuration); final var refreshTokenCookie = AuthUtil.createCookie("refreshToken", refreshToken, domain, refreshTokenDuration); + final var deviceIdCookie = + AuthUtil.createCookie("deviceId", deviceId, domain, refreshTokenDuration); response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, deviceIdCookie.toString()); response.sendRedirect(uiUrl); customLogger.info("Finished"); MDC.remove("REQUEST_ID"); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/AuthTokensDTO.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/AuthTokensDTO.java deleted file mode 100644 index 562fc8fa..00000000 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/AuthTokensDTO.java +++ /dev/null @@ -1,3 +0,0 @@ -package org.bugzkit.api.auth.payload.dto; - -public record AuthTokensDTO(String accessToken, String refreshToken) {} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/DeviceDTO.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/DeviceDTO.java new file mode 100644 index 00000000..2a9144a4 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/payload/dto/DeviceDTO.java @@ -0,0 +1,10 @@ +package org.bugzkit.api.auth.payload.dto; + +import java.time.LocalDateTime; + +public record DeviceDTO( + String deviceId, + String userAgent, + LocalDateTime createdAt, + LocalDateTime lastActiveAt, + boolean current) {} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java new file mode 100644 index 00000000..6ec39a4e --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java @@ -0,0 +1,16 @@ +package org.bugzkit.api.auth.repository; + +import java.util.List; +import java.util.Optional; +import org.bugzkit.api.auth.model.Device; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DeviceRepository extends JpaRepository { + List findAllByUserId(Long userId); + + Optional findByUserIdAndDeviceId(Long userId, String deviceId); + + void deleteByUserIdAndDeviceId(Long userId, String deviceId); + + void deleteAllByUserId(Long userId); +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java index a36f30f0..fcc054e4 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java @@ -1,6 +1,6 @@ package org.bugzkit.api.auth.service; -import org.bugzkit.api.auth.payload.dto.AuthTokensDTO; +import org.bugzkit.api.auth.AuthTokens; import org.bugzkit.api.auth.payload.request.AuthTokensRequest; import org.bugzkit.api.auth.payload.request.ForgotPasswordRequest; import org.bugzkit.api.auth.payload.request.RegisterUserRequest; @@ -12,13 +12,13 @@ public interface AuthService { UserDTO register(RegisterUserRequest registerUserRequest); - AuthTokensDTO authenticate(AuthTokensRequest authTokensRequest, String ipAddress); + AuthTokens authenticate(AuthTokensRequest authTokensRequest, String deviceId, String userAgent); - void deleteTokens(String accessToken, String ipAddress); + void deleteTokens(String accessToken, String deviceId); void deleteTokensOnAllDevices(); - AuthTokensDTO refreshTokens(String refreshToken, String ipAddress); + AuthTokens refreshTokens(String refreshToken, String deviceId, String userAgent); void forgotPassword(ForgotPasswordRequest forgotPasswordRequest); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/DeviceService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/DeviceService.java new file mode 100644 index 00000000..98a86250 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/DeviceService.java @@ -0,0 +1,16 @@ +package org.bugzkit.api.auth.service; + +import java.util.List; +import org.bugzkit.api.auth.payload.dto.DeviceDTO; + +public interface DeviceService { + List findAll(String currentDeviceId); + + void createOrUpdate(Long userId, String deviceId, String userAgent); + + void revoke(String deviceId); + + void deleteByUserIdAndDeviceId(Long userId, String deviceId); + + void deleteAllByUserId(Long userId); +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java index d91cd41b..37e107f8 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java @@ -2,12 +2,12 @@ import java.util.ArrayList; import java.util.Collections; +import org.bugzkit.api.auth.AuthTokens; import org.bugzkit.api.auth.jwt.service.AccessTokenService; import org.bugzkit.api.auth.jwt.service.RefreshTokenService; import org.bugzkit.api.auth.jwt.service.ResetPasswordTokenService; import org.bugzkit.api.auth.jwt.service.VerificationTokenService; import org.bugzkit.api.auth.jwt.util.JwtUtil; -import org.bugzkit.api.auth.payload.dto.AuthTokensDTO; import org.bugzkit.api.auth.payload.request.AuthTokensRequest; import org.bugzkit.api.auth.payload.request.ForgotPasswordRequest; import org.bugzkit.api.auth.payload.request.RegisterUserRequest; @@ -15,6 +15,7 @@ import org.bugzkit.api.auth.payload.request.VerificationEmailRequest; import org.bugzkit.api.auth.payload.request.VerifyEmailRequest; import org.bugzkit.api.auth.service.AuthService; +import org.bugzkit.api.auth.service.DeviceService; import org.bugzkit.api.auth.util.AuthUtil; import org.bugzkit.api.shared.error.exception.BadRequestException; import org.bugzkit.api.shared.error.exception.ConflictException; @@ -42,6 +43,7 @@ public class AuthServiceImpl implements AuthService { private final RefreshTokenService refreshTokenService; private final VerificationTokenService verificationTokenService; private final ResetPasswordTokenService resetPasswordTokenService; + private final DeviceService deviceService; public AuthServiceImpl( UserRepository userRepository, @@ -51,7 +53,8 @@ public AuthServiceImpl( AccessTokenService accessTokenService, RefreshTokenService refreshTokenService, VerificationTokenService verificationTokenService, - ResetPasswordTokenService resetPasswordTokenService) { + ResetPasswordTokenService resetPasswordTokenService, + DeviceService deviceService) { this.userRepository = userRepository; this.roleRepository = roleRepository; this.bCryptPasswordEncoder = bCryptPasswordEncoder; @@ -60,6 +63,7 @@ public AuthServiceImpl( this.refreshTokenService = refreshTokenService; this.verificationTokenService = verificationTokenService; this.resetPasswordTokenService = resetPasswordTokenService; + this.deviceService = deviceService; } @Override @@ -76,7 +80,8 @@ public UserDTO register(RegisterUserRequest registerUserRequest) { } @Override - public AuthTokensDTO authenticate(AuthTokensRequest authTokensRequest, String ipAddress) { + public AuthTokens authenticate( + AuthTokensRequest authTokensRequest, String deviceId, String userAgent) { final var auth = new UsernamePasswordAuthenticationToken( authTokensRequest.usernameOrEmail(), authTokensRequest.password(), new ArrayList<>()); @@ -90,9 +95,10 @@ public AuthTokensDTO authenticate(AuthTokensRequest authTokensRequest, String ip final var accessToken = accessTokenService.create(user.getId(), roleDTOs); final var refreshToken = refreshTokenService - .findByUserIdAndIpAddress(user.getId(), ipAddress) - .orElse(refreshTokenService.create(user.getId(), roleDTOs, ipAddress)); - return new AuthTokensDTO(accessToken, refreshToken); + .findByUserIdAndDeviceId(user.getId(), deviceId) + .orElse(refreshTokenService.create(user.getId(), roleDTOs, deviceId)); + deviceService.createOrUpdate(user.getId(), deviceId, userAgent); + return new AuthTokens(accessToken, refreshToken, deviceId); } private User createUser(RegisterUserRequest registerUserRequest) { @@ -109,30 +115,35 @@ private User createUser(RegisterUserRequest registerUserRequest) { } @Override - public void deleteTokens(String accessToken, String ipAddress) { + @Transactional + public void deleteTokens(String accessToken, String deviceId) { if (!AuthUtil.isSignedIn()) return; final var id = AuthUtil.findSignedInUser().getId(); - refreshTokenService.deleteByUserIdAndIpAddress(id, ipAddress); + refreshTokenService.deleteByUserIdAndDeviceId(id, deviceId); accessTokenService.invalidate(accessToken); + deviceService.deleteByUserIdAndDeviceId(id, deviceId); } @Override + @Transactional public void deleteTokensOnAllDevices() { if (!AuthUtil.isSignedIn()) return; final var id = AuthUtil.findSignedInUser().getId(); refreshTokenService.deleteAllByUserId(id); accessTokenService.invalidateAllByUserId(id); + deviceService.deleteAllByUserId(id); } @Override - public AuthTokensDTO refreshTokens(String refreshToken, String ipAddress) { + public AuthTokens refreshTokens(String refreshToken, String deviceId, String userAgent) { refreshTokenService.check(refreshToken); final var userId = JwtUtil.getUserId(refreshToken); final var roleDTOs = JwtUtil.getRoleDTOs(refreshToken); refreshTokenService.delete(refreshToken); final var newAccessToken = accessTokenService.create(userId, roleDTOs); - final var newRefreshToken = refreshTokenService.create(userId, roleDTOs, ipAddress); - return new AuthTokensDTO(newAccessToken, newRefreshToken); + final var newRefreshToken = refreshTokenService.create(userId, roleDTOs, deviceId); + deviceService.createOrUpdate(userId, deviceId, userAgent); + return new AuthTokens(newAccessToken, newRefreshToken, deviceId); } @Override diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java new file mode 100644 index 00000000..8394f77d --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java @@ -0,0 +1,82 @@ +package org.bugzkit.api.auth.service.impl; + +import java.time.LocalDateTime; +import java.util.List; +import org.bugzkit.api.auth.jwt.service.AccessTokenService; +import org.bugzkit.api.auth.jwt.service.RefreshTokenService; +import org.bugzkit.api.auth.mapper.AuthMapper; +import org.bugzkit.api.auth.model.Device; +import org.bugzkit.api.auth.payload.dto.DeviceDTO; +import org.bugzkit.api.auth.repository.DeviceRepository; +import org.bugzkit.api.auth.service.DeviceService; +import org.bugzkit.api.auth.util.AuthUtil; +import org.bugzkit.api.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DeviceServiceImpl implements DeviceService { + private final DeviceRepository deviceRepository; + private final UserRepository userRepository; + private final AccessTokenService accessTokenService; + private final RefreshTokenService refreshTokenService; + + public DeviceServiceImpl( + DeviceRepository deviceRepository, + UserRepository userRepository, + AccessTokenService accessTokenService, + RefreshTokenService refreshTokenService) { + this.deviceRepository = deviceRepository; + this.userRepository = userRepository; + this.accessTokenService = accessTokenService; + this.refreshTokenService = refreshTokenService; + } + + @Override + public List findAll(String currentDeviceId) { + final var userId = AuthUtil.findSignedInUser().getId(); + return deviceRepository.findAllByUserId(userId).stream() + .map(device -> AuthMapper.INSTANCE.deviceToDeviceDTO(device, currentDeviceId)) + .toList(); + } + + @Override + @Transactional + public void createOrUpdate(Long userId, String deviceId, String userAgent) { + final var existing = deviceRepository.findByUserIdAndDeviceId(userId, deviceId); + if (existing.isPresent()) { + final var device = existing.get(); + device.setUserAgent(userAgent); + device.setLastActiveAt(LocalDateTime.now()); + deviceRepository.save(device); + } else { + deviceRepository.save( + Device.builder() + .user(userRepository.getReferenceById(userId)) + .deviceId(deviceId) + .userAgent(userAgent) + .build()); + } + } + + @Override + @Transactional + public void revoke(String deviceId) { + final var userId = AuthUtil.findSignedInUser().getId(); + deviceRepository.deleteByUserIdAndDeviceId(userId, deviceId); + refreshTokenService.deleteByUserIdAndDeviceId(userId, deviceId); + accessTokenService.invalidateAllByUserId(userId); + } + + @Override + @Transactional + public void deleteByUserIdAndDeviceId(Long userId, String deviceId) { + deviceRepository.deleteByUserIdAndDeviceId(userId, deviceId); + } + + @Override + @Transactional + public void deleteAllByUserId(Long userId) { + deviceRepository.deleteAllByUserId(userId); + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java index 5a2998f4..70b50e71 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; +import java.util.UUID; import org.bugzkit.api.auth.security.UserPrincipal; import org.bugzkit.api.user.model.Role.RoleName; import org.springframework.boot.web.server.Cookie.SameSite; @@ -35,10 +36,9 @@ public static String getAuthName() { return auth.getName(); } - public static String getUserIpAddress(HttpServletRequest request) { - final var ipAddress = request.getHeader("x-forwarded-for"); - if (ipAddress == null || ipAddress.isEmpty()) return request.getRemoteAddr(); - return ipAddress; + public static String getOrCreateDeviceId(HttpServletRequest request) { + final var deviceId = getValueFromCookie("deviceId", request); + return deviceId != null ? deviceId : UUID.randomUUID().toString(); } public static String getValueFromCookie(String name, HttpServletRequest request) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java index 3f085ccf..fbc32a11 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java @@ -9,6 +9,7 @@ import org.bugzkit.api.shared.error.handling.CustomAuthenticationEntryPoint; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -30,7 +31,6 @@ public class SecurityConfig { private static final String[] AUTH_WHITELIST = { Path.AUTH + "/register", Path.AUTH + "/tokens", - Path.AUTH + "/tokens/devices", Path.AUTH + "/tokens/refresh", Path.AUTH + "/password/forgot", Path.AUTH + "/password/reset", @@ -82,6 +82,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests( auth -> auth.requestMatchers(AUTH_WHITELIST) + .permitAll() + .requestMatchers(HttpMethod.DELETE, Path.AUTH + "/tokens/devices") .permitAll() .requestMatchers(USERS_WHITELIST) .permitAll() diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/logger/CustomLogger.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/logger/CustomLogger.java index c092d5af..394026d8 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/logger/CustomLogger.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/logger/CustomLogger.java @@ -14,10 +14,9 @@ public class CustomLogger { public void info(String message) { final var logDetails = new LogDetails(); log.info( - "REQUEST_ID: {}, USER: {}, IP: {}, ENDPOINT: {} {}, MESSAGE: {}", + "REQUEST_ID: {}, USER: {}, ENDPOINT: {} {}, MESSAGE: {}", logDetails.getRequestId(), logDetails.getUsername(), - logDetails.getIpAddress(), logDetails.getRequestMethod(), logDetails.getRequestUrl(), message); @@ -26,10 +25,9 @@ public void info(String message) { public void error(String message, Exception e) { final var logDetails = new LogDetails(); log.error( - "REQUEST_ID: {}, USER: {}, IP: {}, ENDPOINT: {} {}, MESSAGE: {}", + "REQUEST_ID: {}, USER: {}, ENDPOINT: {} {}, MESSAGE: {}", logDetails.getRequestId(), logDetails.getUsername(), - logDetails.getIpAddress(), logDetails.getRequestMethod(), logDetails.getRequestUrl(), message, @@ -39,7 +37,6 @@ public void error(String message, Exception e) { @Getter private static class LogDetails { private final String username; - private final String ipAddress; private final String requestId; private final String requestMethod; private final String requestUrl; @@ -48,7 +45,6 @@ public LogDetails() { final var request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); username = AuthUtil.getAuthName(); - ipAddress = AuthUtil.getUserIpAddress(request); requestId = MDC.get("REQUEST_ID"); requestMethod = request.getMethod(); requestUrl = request.getRequestURL().toString(); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java index 2bd8d346..d806d080 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java @@ -3,6 +3,7 @@ import org.bugzkit.api.auth.jwt.service.AccessTokenService; import org.bugzkit.api.auth.jwt.service.RefreshTokenService; import org.bugzkit.api.auth.jwt.service.VerificationTokenService; +import org.bugzkit.api.auth.service.DeviceService; import org.bugzkit.api.auth.util.AuthUtil; import org.bugzkit.api.shared.error.exception.BadRequestException; import org.bugzkit.api.shared.error.exception.ConflictException; @@ -25,18 +26,21 @@ public class ProfileServiceImpl implements ProfileService { private final AccessTokenService accessTokenService; private final RefreshTokenService refreshTokenService; private final VerificationTokenService verificationTokenService; + private final DeviceService deviceService; public ProfileServiceImpl( UserRepository userRepository, PasswordEncoder bCryptPasswordEncoder, AccessTokenService accessTokenService, RefreshTokenService refreshTokenService, - VerificationTokenService verificationTokenService) { + VerificationTokenService verificationTokenService, + DeviceService deviceService) { this.userRepository = userRepository; this.bCryptPasswordEncoder = bCryptPasswordEncoder; this.accessTokenService = accessTokenService; this.refreshTokenService = refreshTokenService; this.verificationTokenService = verificationTokenService; + this.deviceService = deviceService; } @Override @@ -84,8 +88,10 @@ private void setEmail(User user, String email) { } @Override + @Transactional public void delete() { final var userId = AuthUtil.findSignedInUser().getId(); + deviceService.deleteAllByUserId(userId); deleteAuthTokens(userId); userRepository.deleteById(userId); } diff --git a/backend/spring-boot/src/main/resources/static/openapi.yml b/backend/spring-boot/src/main/resources/static/openapi.yml index e74d50f0..54f4586c 100644 --- a/backend/spring-boot/src/main/resources/static/openapi.yml +++ b/backend/spring-boot/src/main/resources/static/openapi.yml @@ -91,6 +91,34 @@ paths: - "accessToken=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0" - "refreshToken=; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=0" /auth/tokens/devices: + get: + tags: + - auth + summary: Get all logged-in devices + security: + - cookieAuth: [ ] + responses: + 200: + description: Devices retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DeviceDTO" + example: + - deviceId: "550e8400-e29b-41d4-a716-446655440000" + userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + createdAt: "2024-01-15T10:30:00" + lastActiveAt: "2024-01-15T12:00:00" + current: true + - deviceId: "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)" + createdAt: "2024-01-14T08:00:00" + lastActiveAt: "2024-01-14T20:00:00" + current: false + 401: + $ref: "#/components/responses/Unauthorized" delete: tags: - auth @@ -109,6 +137,26 @@ paths: example: - "accessToken=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0" - "refreshToken=; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=0" + /auth/tokens/devices/{deviceId}: + delete: + tags: + - auth + summary: Delete a specific device session + security: + - cookieAuth: [ ] + parameters: + - name: deviceId + in: path + description: Device ID to delete + required: true + schema: + type: string + example: "550e8400-e29b-41d4-a716-446655440000" + responses: + 204: + description: Device session deleted successfully + 401: + $ref: "#/components/responses/Unauthorized" /auth/tokens/refresh: post: tags: @@ -1037,6 +1085,27 @@ components: enum: - USER - ADMIN + DeviceDTO: + type: object + required: + - deviceId + - createdAt + - lastActiveAt + - current + properties: + deviceId: + type: string + userAgent: + type: string + nullable: true + createdAt: + type: string + format: date-time + lastActiveAt: + type: string + format: date-time + current: + type: boolean AvailabilityDTO: type: object required: diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/auth/data/RefreshTokenStoreRepositoryIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/data/RefreshTokenStoreRepositoryIT.java index e8020708..fb47b566 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/auth/data/RefreshTokenStoreRepositoryIT.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/data/RefreshTokenStoreRepositoryIT.java @@ -23,9 +23,9 @@ class RefreshTokenStoreRepositoryIT extends DatabaseContainers { @BeforeEach void setUp() { refreshTokenStoreRepository.deleteAll(); - final var first = new RefreshTokenStore("token1", 1L, "ip1", 1000); - final var second = new RefreshTokenStore("token2", 2L, "ip2", 1000); - final var third = new RefreshTokenStore("token3", 2L, "ip3", 1000); + final var first = new RefreshTokenStore("token1", 1L, "device1", 1000); + final var second = new RefreshTokenStore("token2", 2L, "device2", 1000); + final var third = new RefreshTokenStore("token3", 2L, "device3", 1000); refreshTokenStoreRepository.saveAll(List.of(first, second, third)); } @@ -35,8 +35,8 @@ void cleanUp() { } @Test - void findByUserIdAndIpAddress() { - assertThat(refreshTokenStoreRepository.findByUserIdAndIpAddress(1L, "ip1")).isPresent(); + void findByUserIdAndDeviceId() { + assertThat(refreshTokenStoreRepository.findByUserIdAndDeviceId(1L, "device1")).isPresent(); } @Test diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java index 9ce6b2cc..573a05de 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java @@ -1,6 +1,11 @@ package org.bugzkit.api.auth.integration; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -176,7 +181,8 @@ void deleteTokens() throws Exception { .perform( delete(Path.AUTH + "/tokens") .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("accessToken", authTokens.accessToken()))) + .cookie(new Cookie("accessToken", authTokens.accessToken())) + .cookie(new Cookie("deviceId", authTokens.deviceId()))) .andExpect(status().isNoContent()); invalidAccessToken(authTokens.accessToken()); invalidRefreshToken(authTokens.refreshToken()); @@ -224,7 +230,8 @@ void refreshToken() throws Exception { .perform( post(Path.AUTH + "/tokens/refresh") .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("refreshToken", authTokens.refreshToken()))) + .cookie(new Cookie("refreshToken", authTokens.refreshToken())) + .cookie(new Cookie("deviceId", authTokens.deviceId()))) .andExpect(status().isNoContent()) .andExpect(header().exists(HttpHeaders.SET_COOKIE)); } @@ -379,4 +386,40 @@ void verifyEmail_throwConflict_userAlreadyActivated() throws Exception { .andExpect(status().isConflict()) .andExpect(content().string(containsString("API_ERROR_USER_ACTIVE"))); } + + @Test + void findAllDevices() throws Exception { + final var authTokens = IntegrationTestUtil.authTokens(mockMvc, objectMapper, "user"); + mockMvc + .perform( + get(Path.AUTH + "/tokens/devices") + .cookie(new Cookie("accessToken", authTokens.accessToken())) + .cookie(new Cookie("deviceId", authTokens.deviceId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))) + .andExpect(jsonPath("$[0].deviceId").exists()) + .andExpect(jsonPath("$[0].createdAt").exists()) + .andExpect(jsonPath("$[0].lastActiveAt").exists()) + .andExpect(jsonPath("$[?(@.current == true)]").exists()) + .andExpect(jsonPath("$[*].deviceId", everyItem(is(notNullValue())))); + } + + @Test + void deleteDevice() throws Exception { + final var authTokens = IntegrationTestUtil.authTokens(mockMvc, objectMapper, "update3"); + mockMvc + .perform( + delete(Path.AUTH + "/tokens/devices/" + authTokens.deviceId()) + .cookie(new Cookie("accessToken", authTokens.accessToken()))) + .andExpect(status().isNoContent()); + // After revoking, the access token is blacklisted, so re-authenticate + final var freshTokens = IntegrationTestUtil.authTokens(mockMvc, objectMapper, "update3"); + mockMvc + .perform( + get(Path.AUTH + "/tokens/devices") + .cookie(new Cookie("accessToken", freshTokens.accessToken())) + .cookie(new Cookie("deviceId", freshTokens.deviceId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); + } } diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/auth/unit/AuthMapperTest.java b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/unit/AuthMapperTest.java new file mode 100644 index 00000000..7ffa1ace --- /dev/null +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/unit/AuthMapperTest.java @@ -0,0 +1,49 @@ +package org.bugzkit.api.auth.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import org.bugzkit.api.auth.mapper.AuthMapper; +import org.bugzkit.api.auth.model.Device; +import org.junit.jupiter.api.Test; + +class AuthMapperTest { + @Test + void deviceToDeviceDTO_mapsAllFields() { + final var now = LocalDateTime.now(); + final var device = + Device.builder() + .deviceId("device-1") + .userAgent("Mozilla/5.0") + .createdAt(now) + .lastActiveAt(now) + .build(); + + final var dto = AuthMapper.INSTANCE.deviceToDeviceDTO(device, "device-1"); + + assertEquals("device-1", dto.deviceId()); + assertEquals("Mozilla/5.0", dto.userAgent()); + assertEquals(now, dto.createdAt()); + assertEquals(now, dto.lastActiveAt()); + } + + @Test + void deviceToDeviceDTO_currentTrue_whenDeviceIdMatches() { + final var device = Device.builder().deviceId("device-1").build(); + + final var dto = AuthMapper.INSTANCE.deviceToDeviceDTO(device, "device-1"); + + assertTrue(dto.current()); + } + + @Test + void deviceToDeviceDTO_currentFalse_whenDeviceIdDoesNotMatch() { + final var device = Device.builder().deviceId("device-1").build(); + + final var dto = AuthMapper.INSTANCE.deviceToDeviceDTO(device, "device-2"); + + assertFalse(dto.current()); + } +} diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/AccessingResourcesIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/AccessingResourcesIT.java index b4387957..7b09cc3e 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/AccessingResourcesIT.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/AccessingResourcesIT.java @@ -232,4 +232,22 @@ void getRoles_throwForbidden_userNotAdmin() throws Exception { .andExpect(status().isForbidden()) .andExpect(content().string(containsString(forbidden))); } + + @Test + void findAllDevices_throwUnauthorized_userNotSignedIn() throws Exception { + mockMvc + .perform(get(Path.AUTH + "/tokens/devices").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(containsString(unauthorized))); + } + + @Test + void deleteDevice_throwUnauthorized_userNotSignedIn() throws Exception { + mockMvc + .perform( + delete(Path.AUTH + "/tokens/devices/{deviceId}", "some-device-id") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(containsString(unauthorized))); + } } diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java index aee07e7a..ee59af97 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java @@ -3,7 +3,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import org.bugzkit.api.auth.payload.dto.AuthTokensDTO; +import org.bugzkit.api.auth.AuthTokens; import org.bugzkit.api.auth.payload.request.AuthTokensRequest; import org.bugzkit.api.shared.constants.Path; import org.springframework.http.MediaType; @@ -13,8 +13,8 @@ public class IntegrationTestUtil { private IntegrationTestUtil() {} - public static AuthTokensDTO authTokens( - MockMvc mockMvc, ObjectMapper objectMapper, String username) throws Exception { + public static AuthTokens authTokens(MockMvc mockMvc, ObjectMapper objectMapper, String username) + throws Exception { final var authTokensRequest = new AuthTokensRequest(username, "qwerty123"); final var response = mockMvc @@ -25,8 +25,9 @@ public static AuthTokensDTO authTokens( .andExpect(status().isNoContent()) .andReturn() .getResponse(); - return new AuthTokensDTO( + return new AuthTokens( response.getCookie("accessToken").getValue(), - response.getCookie("refreshToken").getValue()); + response.getCookie("refreshToken").getValue(), + response.getCookie("deviceId").getValue()); } } diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/user/unit/UserMapperTest.java b/backend/spring-boot/src/test/java/org/bugzkit/api/user/unit/UserMapperTest.java new file mode 100644 index 00000000..0ef2ffc2 --- /dev/null +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/user/unit/UserMapperTest.java @@ -0,0 +1,83 @@ +package org.bugzkit.api.user.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.LocalDateTime; +import java.util.Set; +import org.bugzkit.api.user.mapper.UserMapper; +import org.bugzkit.api.user.model.Role; +import org.bugzkit.api.user.model.Role.RoleName; +import org.bugzkit.api.user.model.User; +import org.junit.jupiter.api.Test; + +class UserMapperTest { + @Test + void userToAdminUserDTO_mapsAllFields() { + final var now = LocalDateTime.now(); + final var role = new Role(RoleName.USER); + final var user = + User.builder() + .id(1L) + .username("admin") + .email("admin@localhost") + .active(true) + .lock(false) + .createdAt(now) + .roles(Set.of(role)) + .build(); + + final var dto = UserMapper.INSTANCE.userToAdminUserDTO(user); + + assertEquals(1L, dto.id()); + assertEquals("admin", dto.username()); + assertEquals("admin@localhost", dto.email()); + assertEquals(true, dto.active()); + assertEquals(false, dto.lock()); + assertEquals(now, dto.createdAt()); + assertNotNull(dto.roleDTOs()); + assertEquals(1, dto.roleDTOs().size()); + assertEquals("USER", dto.roleDTOs().iterator().next().name()); + } + + @Test + void userToProfileUserDTO_ignoresActiveLockAndRoles() { + final var user = + User.builder() + .id(1L) + .username("user") + .email("user@localhost") + .active(true) + .lock(false) + .build(); + + final var dto = UserMapper.INSTANCE.userToProfileUserDTO(user); + + assertEquals("user", dto.username()); + assertEquals("user@localhost", dto.email()); + assertNull(dto.active()); + assertNull(dto.lock()); + assertNull(dto.roleDTOs()); + } + + @Test + void userToSimpleUserDTO_ignoresEmailActiveLockAndRoles() { + final var user = + User.builder() + .id(1L) + .username("user") + .email("user@localhost") + .active(true) + .lock(false) + .build(); + + final var dto = UserMapper.INSTANCE.userToSimpleUserDTO(user); + + assertEquals("user", dto.username()); + assertNull(dto.email()); + assertNull(dto.active()); + assertNull(dto.lock()); + assertNull(dto.roleDTOs()); + } +} diff --git a/frontend/svelte-kit/messages/en.json b/frontend/svelte-kit/messages/en.json index 0d3e56d7..de945570 100644 --- a/frontend/svelte-kit/messages/en.json +++ b/frontend/svelte-kit/messages/en.json @@ -29,12 +29,12 @@ "profile": "Profile", "profile_setUsername": "Choose your username", "profile_bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - "profile_personalInformation": "Personal information", - "profile_security": "Security", + "profile_account": "Account", "profile_currentPassword": "Current password", "profile_newPassword": "New password", "profile_confirmNewPassword": "Confirm new password", "profile_changePassword": "Change password", + "profile_password": "Password", "profile_changePasswordSuccess": "Password changed successfully", "profile_signOutFromAllDevices": "Sign out from all devices", "profile_delete": "Delete account", @@ -46,6 +46,14 @@ "profile_email": "Email", "profile_emailInvalid": "Invalid email", "profile_updateSuccess": "Profile updated successfully", + "profile_devices": "Devices", + "profile_devicesUserAgent": "Browser", + "profile_devicesLastActive": "Last active", + "profile_devicesCurrent": "current", + "profile_devicesRevoke": "Revoke", + "profile_devicesRevokeSuccess": "Device revoked successfully", + "profile_devicesRevokeConfirmation": "Are you sure you want to revoke this device session?", + "profile_devicesEmpty": "No active devices", "auth_signIn": "Sign in", "auth_singInWithGoogle": "Sign in with Google", diff --git a/frontend/svelte-kit/messages/sr.json b/frontend/svelte-kit/messages/sr.json index 1f27291a..ae455b80 100644 --- a/frontend/svelte-kit/messages/sr.json +++ b/frontend/svelte-kit/messages/sr.json @@ -29,12 +29,12 @@ "profile": "Profil", "profile_setUsername": "Izaberi korisničko ime", "profile_bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - "profile_personalInformation": "Lični podaci", - "profile_security": "Bezbednost", + "profile_account": "Nalog", "profile_currentPassword": "Trenutna lozinka", "profile_newPassword": "Nova lozinka", "profile_confirmNewPassword": "Potvrdi novu lozinku", "profile_changePassword": "Promena lozinke", + "profile_password": "Lozinka", "profile_changePasswordSuccess": "Uspešno ažuriran lozinka", "profile_signOutFromAllDevices": "Odjavi se sa svih uređaja", "profile_delete": "Obriši nalog", @@ -46,6 +46,14 @@ "profile_email": "Email", "profile_emailInvalid": "Nevalidan email", "profile_updateSuccess": "Uspešno ažuriran profil", + "profile_devices": "Uređaji", + "profile_devicesUserAgent": "Pretraživač", + "profile_devicesLastActive": "Poslednja aktivnost", + "profile_devicesCurrent": "Trenutni", + "profile_devicesRevoke": "Odjavi", + "profile_devicesRevokeSuccess": "Uređaj uspešno odjavljen", + "profile_devicesRevokeConfirmation": "Da li si siguran da želiš da odjaviš ovaj uređaj?", + "profile_devicesEmpty": "Nema aktivnih uređaja", "auth_signIn": "Prijavi se", "auth_singInWithGoogle": "Prijavi se preko Google-a", diff --git a/frontend/svelte-kit/package.json b/frontend/svelte-kit/package.json index 2ea45b8d..fd45c0e2 100644 --- a/frontend/svelte-kit/package.json +++ b/frontend/svelte-kit/package.json @@ -28,6 +28,7 @@ "@types/eslint": "9.6.1", "@types/jsonwebtoken": "9.0.7", "@types/set-cookie-parser": "2.4.10", + "@types/ua-parser-js": "0.7.39", "autoprefixer": "10.4.20", "bits-ui": "1.0.0-next.78", "clsx": "2.1.1", @@ -57,5 +58,8 @@ "vite": "6.0.11", "vitest": "3.0.3", "zod": "3.24.1" + }, + "dependencies": { + "ua-parser-js": "2.0.9" } } diff --git a/frontend/svelte-kit/pnpm-lock.yaml b/frontend/svelte-kit/pnpm-lock.yaml index c607d420..2f9c2142 100644 --- a/frontend/svelte-kit/pnpm-lock.yaml +++ b/frontend/svelte-kit/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + ua-parser-js: + specifier: 2.0.9 + version: 2.0.9 devDependencies: '@inlang/paraglide-js': specifier: 1.11.8 @@ -38,6 +42,9 @@ importers: '@types/set-cookie-parser': specifier: 2.4.10 version: 2.4.10 + '@types/ua-parser-js': + specifier: 0.7.39 + version: 0.7.39 autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.5.1) @@ -846,6 +853,9 @@ packages: '@types/set-cookie-parser@2.4.10': resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/ua-parser-js@0.7.39': + resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} + '@types/validator@13.12.2': resolution: {integrity: sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==} @@ -1237,6 +1247,9 @@ packages: deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + devalue@4.3.3: resolution: {integrity: sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==} @@ -1570,6 +1583,9 @@ packages: is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2314,6 +2330,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.9: + resolution: {integrity: sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==} + hasBin: true + undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} @@ -3299,6 +3322,8 @@ snapshots: dependencies: '@types/node': 22.10.7 + '@types/ua-parser-js@0.7.39': {} + '@types/validator@13.12.2': optional: true @@ -3696,6 +3721,8 @@ snapshots: deprecation@2.3.1: {} + detect-europe-js@0.1.2: {} + devalue@4.3.3: {} devalue@5.1.1: {} @@ -4041,6 +4068,8 @@ snapshots: dependencies: '@types/estree': 1.0.6 + is-standalone-pwa@0.1.1: {} + isexe@2.0.0: {} jackspeak@3.4.3: @@ -4731,6 +4760,14 @@ snapshots: typescript@5.7.3: {} + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.9: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + undici-types@6.20.0: {} universal-github-app-jwt@1.2.0: diff --git a/frontend/svelte-kit/src/hooks.server.ts b/frontend/svelte-kit/src/hooks.server.ts index 46f73619..8f688934 100644 --- a/frontend/svelte-kit/src/hooks.server.ts +++ b/frontend/svelte-kit/src/hooks.server.ts @@ -15,12 +15,16 @@ const tryToGetSignedInUser: Handle = async ({ event, resolve }) => { const { iss } = jwt.verify(accessToken, env.JWT_SECRET) as JwtPayload; event.locals.userId = iss; } catch (_) { - await tryToRefreshToken(event.cookies, event.locals); + await tryToRefreshToken(event.cookies, event.locals, event.request); } return await resolve(event); }; -async function tryToRefreshToken(cookies: Cookies, locals: App.Locals): Promise { +async function tryToRefreshToken( + cookies: Cookies, + locals: App.Locals, + request: Request, +): Promise { try { const refreshToken = cookies.get('refreshToken') ?? ''; jwt.verify(refreshToken, env.JWT_SECRET); @@ -30,6 +34,7 @@ async function tryToRefreshToken(cookies: Cookies, locals: App.Locals): Promise< path: '/auth/tokens/refresh', }, cookies, + request, ); if ('error' in response) { diff --git a/frontend/svelte-kit/src/lib/models/auth/device.ts b/frontend/svelte-kit/src/lib/models/auth/device.ts new file mode 100644 index 00000000..34f446cf --- /dev/null +++ b/frontend/svelte-kit/src/lib/models/auth/device.ts @@ -0,0 +1,7 @@ +export interface Device { + deviceId: string; + userAgent: string | null; + createdAt: string; + lastActiveAt: string; + current: boolean; +} diff --git a/frontend/svelte-kit/src/lib/server/apis/api.ts b/frontend/svelte-kit/src/lib/server/apis/api.ts index b5824b31..9551a052 100644 --- a/frontend/svelte-kit/src/lib/server/apis/api.ts +++ b/frontend/svelte-kit/src/lib/server/apis/api.ts @@ -14,14 +14,20 @@ interface RequestParams { export async function makeRequest( params: RequestParams, cookies: Cookies, + request?: Request, ): Promise { const opts: RequestInit = {}; const headers = new Headers(); const accessToken = cookies.get('accessToken'); const refreshToken = cookies.get('refreshToken'); + const deviceId = cookies.get('deviceId'); if (accessToken) headers.append('Cookie', `accessToken=${accessToken}`); if (refreshToken) headers.append('Cookie', `refreshToken=${refreshToken}`); + if (deviceId) headers.append('Cookie', `deviceId=${deviceId}`); + + const userAgent = request?.headers.get('User-Agent'); + if (userAgent) headers.set('User-Agent', userAgent); if (params.body) { headers.append('Content-Type', 'application/json'); diff --git a/frontend/svelte-kit/src/lib/server/utils/util.ts b/frontend/svelte-kit/src/lib/server/utils/util.ts index 98bf072d..b4a1c7cc 100644 --- a/frontend/svelte-kit/src/lib/server/utils/util.ts +++ b/frontend/svelte-kit/src/lib/server/utils/util.ts @@ -43,6 +43,7 @@ export function setCookieFromString(cookie: string, cookies: Cookies) { export function removeAuth(cookies: Cookies, locals: App.Locals): void { cookies.delete('accessToken', { path: '/' }); cookies.delete('refreshToken', { path: '/' }); + cookies.delete('deviceId', { path: '/' }); locals.userId = null; } diff --git a/frontend/svelte-kit/src/routes/+layout.server.ts b/frontend/svelte-kit/src/routes/+layout.server.ts index f6525f82..713de0b7 100644 --- a/frontend/svelte-kit/src/routes/+layout.server.ts +++ b/frontend/svelte-kit/src/routes/+layout.server.ts @@ -1,7 +1,7 @@ import type { Profile } from '$lib/models/user/user'; import { languageTag } from '$lib/paraglide/runtime'; import { makeRequest } from '$lib/server/apis/api'; -import { HttpRequest, isAdmin, removeAuth } from '$lib/server/utils/util'; +import { HttpRequest, isAdmin } from '$lib/server/utils/util'; import { error, redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; @@ -17,7 +17,7 @@ export const load = (async ({ locals, cookies, url }) => { ); if ('error' in response) { - if (response.status == 401) removeAuth(cookies, locals); + if (response.status == 401) cookies.delete('accessToken', { path: '/' }); error(response.status, { message: response.error }); } diff --git a/frontend/svelte-kit/src/routes/auth/sign-in/+page.server.ts b/frontend/svelte-kit/src/routes/auth/sign-in/+page.server.ts index 82c67224..c2d077ce 100644 --- a/frontend/svelte-kit/src/routes/auth/sign-in/+page.server.ts +++ b/frontend/svelte-kit/src/routes/auth/sign-in/+page.server.ts @@ -26,6 +26,7 @@ export const actions = { body: JSON.stringify(form.data), }, cookies, + request, ); if ('error' in response) return apiErrors(response, form); diff --git a/frontend/svelte-kit/src/routes/profile/settings/(components)/personal-information.svelte b/frontend/svelte-kit/src/routes/profile/settings/(components)/account.svelte similarity index 51% rename from frontend/svelte-kit/src/routes/profile/settings/(components)/personal-information.svelte rename to frontend/svelte-kit/src/routes/profile/settings/(components)/account.svelte index 77b86f9d..79fd8ca5 100644 --- a/frontend/svelte-kit/src/routes/profile/settings/(components)/personal-information.svelte +++ b/frontend/svelte-kit/src/routes/profile/settings/(components)/account.svelte @@ -1,15 +1,19 @@ - - -
+ + + {#snippet children({ props })} @@ -61,5 +76,32 @@ {m.general_save()} {/if} + + + + + + {#snippet child({ props })} + + {/snippet} + + + + {m.profile_delete()} + {m.profile_deleteAccountConfirmation()} + + + {m.general_cancel()} +
+ (deleteDialogOpen = false)} + > + {m.general_delete()} + +
+
+
+
diff --git a/frontend/svelte-kit/src/routes/profile/settings/(components)/devices.svelte b/frontend/svelte-kit/src/routes/profile/settings/(components)/devices.svelte new file mode 100644 index 00000000..b4ee0142 --- /dev/null +++ b/frontend/svelte-kit/src/routes/profile/settings/(components)/devices.svelte @@ -0,0 +1,128 @@ + + + + + {#if data.devices.length === 0} +

{m.profile_devicesEmpty()}

+ {:else} + + + + {m.profile_devicesUserAgent()} + {m.profile_devicesLastActive()} + + + + + {#each data.devices as device} + + + {parseUserAgent(device.userAgent)} + {#if device.current} + ({m.profile_devicesCurrent()}) + {/if} + + + {new Date(device.lastActiveAt).toLocaleString()} + + + {#if !device.current} + + {/if} + + + {/each} + + + + + + + {/if} + + + + + {m.profile_devicesRevoke()} + {m.profile_devicesRevokeConfirmation()} + + + {m.general_cancel()} +
+ + (dialogOpen = false)} + > + {m.profile_devicesRevoke()} + +
+
+
+
+
+
diff --git a/frontend/svelte-kit/src/routes/profile/settings/(components)/security.svelte b/frontend/svelte-kit/src/routes/profile/settings/(components)/password.svelte similarity index 60% rename from frontend/svelte-kit/src/routes/profile/settings/(components)/security.svelte rename to frontend/svelte-kit/src/routes/profile/settings/(components)/password.svelte index 729c609e..bcdd3d69 100644 --- a/frontend/svelte-kit/src/routes/profile/settings/(components)/security.svelte +++ b/frontend/svelte-kit/src/routes/profile/settings/(components)/password.svelte @@ -1,18 +1,15 @@ - - + +
{m.general_save()} {/if}
- - - - - - - - - - {#snippet child({ props })} - - {/snippet} - - - - {m.profile_delete()} - {m.profile_deleteAccountConfirmation()} - - - {m.general_cancel()} -
- (deleteDialogOpen = false)} - > - {m.general_delete()} - -
-
-
-
diff --git a/frontend/svelte-kit/src/routes/profile/settings/+page.server.ts b/frontend/svelte-kit/src/routes/profile/settings/+page.server.ts index 623fea9a..e984263b 100644 --- a/frontend/svelte-kit/src/routes/profile/settings/+page.server.ts +++ b/frontend/svelte-kit/src/routes/profile/settings/+page.server.ts @@ -1,19 +1,34 @@ +import type { Device } from '$lib/models/auth/device'; import * as m from '$lib/paraglide/messages.js'; import { apiErrors, makeRequest } from '$lib/server/apis/api'; import { HttpRequest, removeAuth } from '$lib/server/utils/util'; -import { redirect } from '@sveltejs/kit'; +import { error, redirect } from '@sveltejs/kit'; import { fail, message, superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; import type { Actions, PageServerLoad } from './$types'; -import { changePasswordSchema, deleteSchema, updateProfileSchema } from './schema'; +import { + changePasswordSchema, + deleteSchema, + revokeDeviceSchema, + updateProfileSchema, +} from './schema'; -export const load = (async ({ parent }) => { +export const load = (async ({ parent, cookies }) => { const changePasswordForm = await superValidate(zod(changePasswordSchema)); const deleteForm = await superValidate(zod(deleteSchema)); + const revokeDeviceForm = await superValidate(zod(revokeDeviceSchema)); const { profile } = await parent(); const updateProfileInitialData = { username: profile?.username, email: profile?.email }; const updateProfileForm = await superValidate(updateProfileInitialData, zod(updateProfileSchema)); - return { updateProfileForm, changePasswordForm, deleteForm, profile }; + + const devicesResponse = await makeRequest( + { method: HttpRequest.GET, path: '/auth/tokens/devices' }, + cookies, + ); + if ('error' in devicesResponse) error(devicesResponse.status, { message: devicesResponse.error }); + const devices = devicesResponse as unknown as Device[]; + + return { updateProfileForm, changePasswordForm, deleteForm, revokeDeviceForm, devices, profile }; }) satisfies PageServerLoad; export const actions = { @@ -59,6 +74,7 @@ export const actions = { }), }, cookies, + request, ); if ('error' in signInResponse) return apiErrors(signInResponse, form); @@ -81,4 +97,20 @@ export const actions = { removeAuth(cookies, locals); redirect(302, '/'); }, + revokeDevice: async ({ request, cookies }) => { + const form = await superValidate(request, zod(revokeDeviceSchema)); + if (!form.valid) return fail(400, { form }); + + const response = await makeRequest( + { + method: HttpRequest.DELETE, + path: `/auth/tokens/devices/${form.data.deviceId}`, + }, + cookies, + ); + + if ('error' in response) return apiErrors(response, form); + + return message(form, m.profile_devicesRevokeSuccess()); + }, } satisfies Actions; diff --git a/frontend/svelte-kit/src/routes/profile/settings/+page.svelte b/frontend/svelte-kit/src/routes/profile/settings/+page.svelte index b3fb4b60..af3c2bd5 100644 --- a/frontend/svelte-kit/src/routes/profile/settings/+page.svelte +++ b/frontend/svelte-kit/src/routes/profile/settings/+page.svelte @@ -2,8 +2,9 @@ import * as Tabs from '$lib/components/ui/tabs/index.js'; import * as m from '$lib/paraglide/messages.js'; import type { PageProps } from './$types'; - import PersonalInformation from './(components)/personal-information.svelte'; - import Security from './(components)/security.svelte'; + import Account from './(components)/account.svelte'; + import Devices from './(components)/devices.svelte'; + import Password from './(components)/password.svelte'; const { data }: PageProps = $props(); @@ -11,20 +12,26 @@
- - - - {m.profile_personalInformation()} + + + + {m.profile_account()} - - {m.profile_security()} + + {m.profile_password()} + + + {m.profile_devices()} - - + + + + + - - + +
diff --git a/frontend/svelte-kit/src/routes/profile/settings/schema.ts b/frontend/svelte-kit/src/routes/profile/settings/schema.ts index 6d6611d7..7bfeca04 100644 --- a/frontend/svelte-kit/src/routes/profile/settings/schema.ts +++ b/frontend/svelte-kit/src/routes/profile/settings/schema.ts @@ -24,3 +24,7 @@ export const changePasswordSchema = z }); export const deleteSchema = z.object({}); + +export const revokeDeviceSchema = z.object({ + deviceId: z.string(), +}); From aed849a90a96a75820e4e7d239eefae21346fae0 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 17 Feb 2026 01:28:18 +0100 Subject: [PATCH 2/7] save deviceId in jwt instead of cookie --- .../java/org/bugzkit/api/auth/AuthTokens.java | 2 +- .../api/auth/controller/AuthController.java | 21 +++++++------------ .../auth/jwt/service/AccessTokenService.java | 2 +- .../service/impl/AccessTokenServiceImpl.java | 3 ++- .../service/impl/RefreshTokenServiceImpl.java | 1 + .../bugzkit/api/auth/jwt/util/JwtUtil.java | 4 ++++ .../api/auth/oauth2/OAuth2SuccessHandler.java | 7 ++----- .../bugzkit/api/auth/service/AuthService.java | 4 ++-- .../auth/service/impl/AuthServiceImpl.java | 14 +++++++------ .../org/bugzkit/api/auth/util/AuthUtil.java | 5 ++--- .../auth/integration/AuthControllerIT.java | 16 +++++++------- .../api/shared/util/IntegrationTestUtil.java | 3 +-- .../src/lib/models/auth/jwt-payload.ts | 1 + .../svelte-kit/src/lib/server/apis/api.ts | 2 -- .../svelte-kit/src/lib/server/utils/util.ts | 1 - 15 files changed, 40 insertions(+), 46 deletions(-) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java index f261dcf5..8f739cbe 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/AuthTokens.java @@ -1,3 +1,3 @@ package org.bugzkit.api.auth; -public record AuthTokens(String accessToken, String refreshToken, String deviceId) {} +public record AuthTokens(String accessToken, String refreshToken) {} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java index 10f5fb94..fa16487b 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java @@ -4,6 +4,7 @@ import jakarta.validation.Valid; import java.util.List; import org.bugzkit.api.auth.AuthTokens; +import org.bugzkit.api.auth.jwt.util.JwtUtil; import org.bugzkit.api.auth.payload.dto.DeviceDTO; import org.bugzkit.api.auth.payload.request.AuthTokensRequest; import org.bugzkit.api.auth.payload.request.ForgotPasswordRequest; @@ -57,7 +58,7 @@ public ResponseEntity register( @PostMapping("/tokens") public ResponseEntity authenticate( @Valid @RequestBody AuthTokensRequest authTokensRequest, HttpServletRequest request) { - final var deviceId = AuthUtil.getOrCreateDeviceId(request); + final var deviceId = AuthUtil.generateDeviceId(); final var userAgent = request.getHeader("User-Agent"); final var authTokens = authService.authenticate(authTokensRequest, deviceId, userAgent); return buildTokenResponse(authTokens); @@ -66,23 +67,21 @@ public ResponseEntity authenticate( @DeleteMapping("/tokens") public ResponseEntity deleteTokens(HttpServletRequest request) { final var accessToken = AuthUtil.getValueFromCookie("accessToken", request); - final var deviceId = AuthUtil.getValueFromCookie("deviceId", request); - authService.deleteTokens(accessToken, deviceId); - final var authTokens = new AuthTokens("", "", ""); - return buildTokenResponse(authTokens); + authService.deleteTokens(accessToken); + return buildTokenResponse(new AuthTokens("", "")); } @GetMapping("/tokens/devices") public ResponseEntity> findAllDevices(HttpServletRequest request) { - final var deviceId = AuthUtil.getValueFromCookie("deviceId", request); + final var accessToken = AuthUtil.getValueFromCookie("accessToken", request); + final var deviceId = JwtUtil.getDeviceId(accessToken); return ResponseEntity.ok(deviceService.findAll(deviceId)); } @DeleteMapping("/tokens/devices") public ResponseEntity deleteTokensOnAllDevices() { authService.deleteTokensOnAllDevices(); - final var authTokens = new AuthTokens("", "", ""); - return buildTokenResponse(authTokens); + return buildTokenResponse(new AuthTokens("", "")); } @DeleteMapping("/tokens/devices/{deviceId}") @@ -94,9 +93,8 @@ public ResponseEntity revokeDevice(@PathVariable String deviceId) { @PostMapping("/tokens/refresh") public ResponseEntity refreshTokens(HttpServletRequest request) { final var refreshToken = AuthUtil.getValueFromCookie("refreshToken", request); - final var deviceId = AuthUtil.getOrCreateDeviceId(request); final var userAgent = request.getHeader("User-Agent"); - final var authTokens = authService.refreshTokens(refreshToken, deviceId, userAgent); + final var authTokens = authService.refreshTokens(refreshToken, userAgent); return buildTokenResponse(authTokens); } @@ -134,12 +132,9 @@ private ResponseEntity buildTokenResponse(AuthTokens authTokens) { final var refreshTokenCookie = AuthUtil.createCookie( "refreshToken", authTokens.refreshToken(), domain, refreshTokenDuration); - final var deviceIdCookie = - AuthUtil.createCookie("deviceId", authTokens.deviceId(), domain, refreshTokenDuration); return ResponseEntity.noContent() .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - .header(HttpHeaders.SET_COOKIE, deviceIdCookie.toString()) .build(); } } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/AccessTokenService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/AccessTokenService.java index 010f860c..6eb6d5b7 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/AccessTokenService.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/AccessTokenService.java @@ -4,7 +4,7 @@ import org.bugzkit.api.user.payload.dto.RoleDTO; public interface AccessTokenService { - String create(Long userId, Set roleDTOs); + String create(Long userId, Set roleDTOs, String deviceId); void check(String token); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/AccessTokenServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/AccessTokenServiceImpl.java index b77102ea..d99f8eca 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/AccessTokenServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/AccessTokenServiceImpl.java @@ -35,11 +35,12 @@ public AccessTokenServiceImpl( } @Override - public String create(Long userId, Set roleDTOs) { + public String create(Long userId, Set roleDTOs, String deviceId) { return JWT.create() .withIssuer(userId.toString()) .withClaim("roles", roleDTOs.stream().map(RoleDTO::name).toList()) .withClaim("purpose", PURPOSE.name()) + .withClaim("deviceId", deviceId) .withIssuedAt(Instant.now()) .withExpiresAt(Instant.now().plusSeconds(tokenDuration)) .sign(JwtUtil.getAlgorithm(secret)); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java index 65c9791a..9085d63e 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java @@ -36,6 +36,7 @@ public String create(Long userId, Set roleDTOs, String deviceId) { .withIssuer(userId.toString()) .withClaim("roles", roleDTOs.stream().map(RoleDTO::name).toList()) .withClaim("purpose", PURPOSE.name()) + .withClaim("deviceId", deviceId) .withIssuedAt(Instant.now()) .withExpiresAt(Instant.now().plusSeconds(tokenDuration)) .sign(JwtUtil.getAlgorithm(secret)); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/util/JwtUtil.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/util/JwtUtil.java index cf91c1eb..6e79a52d 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/util/JwtUtil.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/util/JwtUtil.java @@ -30,6 +30,10 @@ public static Set getRoleDTOs(String token) { return roles.stream().map(RoleDTO::new).collect(Collectors.toSet()); } + public static String getDeviceId(String token) { + return JWT.decode(token).getClaim("deviceId").asString(); + } + public static Instant getIssuedAt(String token) { return JWT.decode(token).getIssuedAtAsInstant(); } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java index 45a314d0..8d19f4a1 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java @@ -56,8 +56,8 @@ public void onAuthenticationSuccess( userPrincipal.getAuthorities().stream() .map(authority -> new RoleDTO(authority.getAuthority())) .collect(Collectors.toSet()); - final var deviceId = AuthUtil.getOrCreateDeviceId(request); - final var accessToken = accessTokenService.create(userPrincipal.getId(), roleDTOs); + final var deviceId = AuthUtil.generateDeviceId(); + final var accessToken = accessTokenService.create(userPrincipal.getId(), roleDTOs, deviceId); final var refreshToken = refreshTokenService.create(userPrincipal.getId(), roleDTOs, deviceId); final var userAgent = request.getHeader("User-Agent"); deviceService.createOrUpdate(userPrincipal.getId(), deviceId, userAgent); @@ -65,11 +65,8 @@ public void onAuthenticationSuccess( AuthUtil.createCookie("accessToken", accessToken, domain, accessTokenDuration); final var refreshTokenCookie = AuthUtil.createCookie("refreshToken", refreshToken, domain, refreshTokenDuration); - final var deviceIdCookie = - AuthUtil.createCookie("deviceId", deviceId, domain, refreshTokenDuration); response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); - response.addHeader(HttpHeaders.SET_COOKIE, deviceIdCookie.toString()); response.sendRedirect(uiUrl); customLogger.info("Finished"); MDC.remove("REQUEST_ID"); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java index fcc054e4..15ed3a93 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/AuthService.java @@ -14,11 +14,11 @@ public interface AuthService { AuthTokens authenticate(AuthTokensRequest authTokensRequest, String deviceId, String userAgent); - void deleteTokens(String accessToken, String deviceId); + void deleteTokens(String accessToken); void deleteTokensOnAllDevices(); - AuthTokens refreshTokens(String refreshToken, String deviceId, String userAgent); + AuthTokens refreshTokens(String refreshToken, String userAgent); void forgotPassword(ForgotPasswordRequest forgotPasswordRequest); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java index 37e107f8..ba8dedf1 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java @@ -92,13 +92,13 @@ public AuthTokens authenticate( .findWithRolesByUsername(auth.getName()) .orElseThrow(() -> new UnauthorizedException("auth.unauthorized")); final var roleDTOs = UserMapper.INSTANCE.rolesToRoleDTOs(user.getRoles()); - final var accessToken = accessTokenService.create(user.getId(), roleDTOs); + final var accessToken = accessTokenService.create(user.getId(), roleDTOs, deviceId); final var refreshToken = refreshTokenService .findByUserIdAndDeviceId(user.getId(), deviceId) .orElse(refreshTokenService.create(user.getId(), roleDTOs, deviceId)); deviceService.createOrUpdate(user.getId(), deviceId, userAgent); - return new AuthTokens(accessToken, refreshToken, deviceId); + return new AuthTokens(accessToken, refreshToken); } private User createUser(RegisterUserRequest registerUserRequest) { @@ -116,9 +116,10 @@ private User createUser(RegisterUserRequest registerUserRequest) { @Override @Transactional - public void deleteTokens(String accessToken, String deviceId) { + public void deleteTokens(String accessToken) { if (!AuthUtil.isSignedIn()) return; final var id = AuthUtil.findSignedInUser().getId(); + final var deviceId = JwtUtil.getDeviceId(accessToken); refreshTokenService.deleteByUserIdAndDeviceId(id, deviceId); accessTokenService.invalidate(accessToken); deviceService.deleteByUserIdAndDeviceId(id, deviceId); @@ -135,15 +136,16 @@ public void deleteTokensOnAllDevices() { } @Override - public AuthTokens refreshTokens(String refreshToken, String deviceId, String userAgent) { + public AuthTokens refreshTokens(String refreshToken, String userAgent) { refreshTokenService.check(refreshToken); final var userId = JwtUtil.getUserId(refreshToken); final var roleDTOs = JwtUtil.getRoleDTOs(refreshToken); + final var deviceId = JwtUtil.getDeviceId(refreshToken); refreshTokenService.delete(refreshToken); - final var newAccessToken = accessTokenService.create(userId, roleDTOs); + final var newAccessToken = accessTokenService.create(userId, roleDTOs, deviceId); final var newRefreshToken = refreshTokenService.create(userId, roleDTOs, deviceId); deviceService.createOrUpdate(userId, deviceId, userAgent); - return new AuthTokens(newAccessToken, newRefreshToken, deviceId); + return new AuthTokens(newAccessToken, newRefreshToken); } @Override diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java index 70b50e71..4368e359 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java @@ -36,9 +36,8 @@ public static String getAuthName() { return auth.getName(); } - public static String getOrCreateDeviceId(HttpServletRequest request) { - final var deviceId = getValueFromCookie("deviceId", request); - return deviceId != null ? deviceId : UUID.randomUUID().toString(); + public static String generateDeviceId() { + return UUID.randomUUID().toString(); } public static String getValueFromCookie(String name, HttpServletRequest request) { diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java index 573a05de..96936862 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/auth/integration/AuthControllerIT.java @@ -20,6 +20,7 @@ import jakarta.servlet.http.Cookie; import org.bugzkit.api.auth.jwt.service.impl.ResetPasswordTokenServiceImpl; import org.bugzkit.api.auth.jwt.service.impl.VerificationTokenServiceImpl; +import org.bugzkit.api.auth.jwt.util.JwtUtil; import org.bugzkit.api.auth.payload.request.AuthTokensRequest; import org.bugzkit.api.auth.payload.request.ForgotPasswordRequest; import org.bugzkit.api.auth.payload.request.RegisterUserRequest; @@ -181,8 +182,7 @@ void deleteTokens() throws Exception { .perform( delete(Path.AUTH + "/tokens") .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("accessToken", authTokens.accessToken())) - .cookie(new Cookie("deviceId", authTokens.deviceId()))) + .cookie(new Cookie("accessToken", authTokens.accessToken()))) .andExpect(status().isNoContent()); invalidAccessToken(authTokens.accessToken()); invalidRefreshToken(authTokens.refreshToken()); @@ -230,8 +230,7 @@ void refreshToken() throws Exception { .perform( post(Path.AUTH + "/tokens/refresh") .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("refreshToken", authTokens.refreshToken())) - .cookie(new Cookie("deviceId", authTokens.deviceId()))) + .cookie(new Cookie("refreshToken", authTokens.refreshToken()))) .andExpect(status().isNoContent()) .andExpect(header().exists(HttpHeaders.SET_COOKIE)); } @@ -393,8 +392,7 @@ void findAllDevices() throws Exception { mockMvc .perform( get(Path.AUTH + "/tokens/devices") - .cookie(new Cookie("accessToken", authTokens.accessToken())) - .cookie(new Cookie("deviceId", authTokens.deviceId()))) + .cookie(new Cookie("accessToken", authTokens.accessToken()))) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))) .andExpect(jsonPath("$[0].deviceId").exists()) @@ -407,9 +405,10 @@ void findAllDevices() throws Exception { @Test void deleteDevice() throws Exception { final var authTokens = IntegrationTestUtil.authTokens(mockMvc, objectMapper, "update3"); + final var deviceId = JwtUtil.getDeviceId(authTokens.accessToken()); mockMvc .perform( - delete(Path.AUTH + "/tokens/devices/" + authTokens.deviceId()) + delete(Path.AUTH + "/tokens/devices/" + deviceId) .cookie(new Cookie("accessToken", authTokens.accessToken()))) .andExpect(status().isNoContent()); // After revoking, the access token is blacklisted, so re-authenticate @@ -417,8 +416,7 @@ void deleteDevice() throws Exception { mockMvc .perform( get(Path.AUTH + "/tokens/devices") - .cookie(new Cookie("accessToken", freshTokens.accessToken())) - .cookie(new Cookie("deviceId", freshTokens.deviceId()))) + .cookie(new Cookie("accessToken", freshTokens.accessToken()))) .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(1))); } diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java index ee59af97..dcca7d14 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/util/IntegrationTestUtil.java @@ -27,7 +27,6 @@ public static AuthTokens authTokens(MockMvc mockMvc, ObjectMapper objectMapper, .getResponse(); return new AuthTokens( response.getCookie("accessToken").getValue(), - response.getCookie("refreshToken").getValue(), - response.getCookie("deviceId").getValue()); + response.getCookie("refreshToken").getValue()); } } diff --git a/frontend/svelte-kit/src/lib/models/auth/jwt-payload.ts b/frontend/svelte-kit/src/lib/models/auth/jwt-payload.ts index 4e4c0dfa..03214fd0 100644 --- a/frontend/svelte-kit/src/lib/models/auth/jwt-payload.ts +++ b/frontend/svelte-kit/src/lib/models/auth/jwt-payload.ts @@ -6,6 +6,7 @@ export interface JwtPayload { exp: number; purpose: JwtPurpose; roles?: RoleName[]; + deviceId?: string; } export enum JwtPurpose { diff --git a/frontend/svelte-kit/src/lib/server/apis/api.ts b/frontend/svelte-kit/src/lib/server/apis/api.ts index 9551a052..f0394810 100644 --- a/frontend/svelte-kit/src/lib/server/apis/api.ts +++ b/frontend/svelte-kit/src/lib/server/apis/api.ts @@ -21,10 +21,8 @@ export async function makeRequest( const accessToken = cookies.get('accessToken'); const refreshToken = cookies.get('refreshToken'); - const deviceId = cookies.get('deviceId'); if (accessToken) headers.append('Cookie', `accessToken=${accessToken}`); if (refreshToken) headers.append('Cookie', `refreshToken=${refreshToken}`); - if (deviceId) headers.append('Cookie', `deviceId=${deviceId}`); const userAgent = request?.headers.get('User-Agent'); if (userAgent) headers.set('User-Agent', userAgent); diff --git a/frontend/svelte-kit/src/lib/server/utils/util.ts b/frontend/svelte-kit/src/lib/server/utils/util.ts index b4a1c7cc..98bf072d 100644 --- a/frontend/svelte-kit/src/lib/server/utils/util.ts +++ b/frontend/svelte-kit/src/lib/server/utils/util.ts @@ -43,7 +43,6 @@ export function setCookieFromString(cookie: string, cookies: Cookies) { export function removeAuth(cookies: Cookies, locals: App.Locals): void { cookies.delete('accessToken', { path: '/' }); cookies.delete('refreshToken', { path: '/' }); - cookies.delete('deviceId', { path: '/' }); locals.userId = null; } From 6373d5f2209cb70a442a83a9e5264fabccff1d52 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 17 Feb 2026 01:38:35 +0100 Subject: [PATCH 3/7] set/remove auth token cookies --- .../api/auth/controller/AuthController.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java index fa16487b..a212102c 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java @@ -61,14 +61,14 @@ public ResponseEntity authenticate( final var deviceId = AuthUtil.generateDeviceId(); final var userAgent = request.getHeader("User-Agent"); final var authTokens = authService.authenticate(authTokensRequest, deviceId, userAgent); - return buildTokenResponse(authTokens); + return setAuthTokenCookies(authTokens); } @DeleteMapping("/tokens") public ResponseEntity deleteTokens(HttpServletRequest request) { final var accessToken = AuthUtil.getValueFromCookie("accessToken", request); authService.deleteTokens(accessToken); - return buildTokenResponse(new AuthTokens("", "")); + return removeAuthTokenCookies(); } @GetMapping("/tokens/devices") @@ -81,7 +81,7 @@ public ResponseEntity> findAllDevices(HttpServletRequest request @DeleteMapping("/tokens/devices") public ResponseEntity deleteTokensOnAllDevices() { authService.deleteTokensOnAllDevices(); - return buildTokenResponse(new AuthTokens("", "")); + return removeAuthTokenCookies(); } @DeleteMapping("/tokens/devices/{deviceId}") @@ -95,7 +95,7 @@ public ResponseEntity refreshTokens(HttpServletRequest request) { final var refreshToken = AuthUtil.getValueFromCookie("refreshToken", request); final var userAgent = request.getHeader("User-Agent"); final var authTokens = authService.refreshTokens(refreshToken, userAgent); - return buildTokenResponse(authTokens); + return setAuthTokenCookies(authTokens); } @PostMapping("/password/forgot") @@ -126,7 +126,7 @@ public ResponseEntity verifyEmail( return ResponseEntity.noContent().build(); } - private ResponseEntity buildTokenResponse(AuthTokens authTokens) { + private ResponseEntity setAuthTokenCookies(AuthTokens authTokens) { final var accessTokenCookie = AuthUtil.createCookie("accessToken", authTokens.accessToken(), domain, accessTokenDuration); final var refreshTokenCookie = @@ -137,4 +137,13 @@ private ResponseEntity buildTokenResponse(AuthTokens authTokens) { .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) .build(); } + + private ResponseEntity removeAuthTokenCookies() { + final var accessTokenCookie = AuthUtil.createCookie("accessToken", "", domain, 0); + final var refreshTokenCookie = AuthUtil.createCookie("refreshToken", "", domain, 0); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + .build(); + } } From e64bac4221d4ac5385d5209f7185ce924dff7c90 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 17 Feb 2026 01:54:38 +0100 Subject: [PATCH 4/7] remove findByuserIdAndDeviceId --- .../bugzkit/api/auth/jwt/service/RefreshTokenService.java | 3 --- .../auth/jwt/service/impl/RefreshTokenServiceImpl.java | 8 -------- .../bugzkit/api/auth/service/impl/AuthServiceImpl.java | 5 +---- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java index b7f9189e..6c9e406b 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java @@ -1,6 +1,5 @@ package org.bugzkit.api.auth.jwt.service; -import java.util.Optional; import java.util.Set; import org.bugzkit.api.user.payload.dto.RoleDTO; @@ -9,8 +8,6 @@ public interface RefreshTokenService { void check(String token); - Optional findByUserIdAndDeviceId(Long userId, String deviceId); - void delete(String token); void deleteByUserIdAndDeviceId(Long userId, String deviceId); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java index 9085d63e..47233627 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/impl/RefreshTokenServiceImpl.java @@ -2,7 +2,6 @@ import com.auth0.jwt.JWT; import java.time.Instant; -import java.util.Optional; import java.util.Set; import org.bugzkit.api.auth.jwt.redis.model.RefreshTokenStore; import org.bugzkit.api.auth.jwt.redis.repository.RefreshTokenStoreRepository; @@ -63,13 +62,6 @@ private void isInRefreshTokenStore(String token) { throw new BadRequestException("auth.tokenInvalid"); } - @Override - public Optional findByUserIdAndDeviceId(Long userId, String deviceId) { - return refreshTokenStoreRepository - .findByUserIdAndDeviceId(userId, deviceId) - .map(RefreshTokenStore::getRefreshToken); - } - @Override public void delete(String token) { refreshTokenStoreRepository.deleteById(token); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java index ba8dedf1..61ee1a71 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/AuthServiceImpl.java @@ -93,10 +93,7 @@ public AuthTokens authenticate( .orElseThrow(() -> new UnauthorizedException("auth.unauthorized")); final var roleDTOs = UserMapper.INSTANCE.rolesToRoleDTOs(user.getRoles()); final var accessToken = accessTokenService.create(user.getId(), roleDTOs, deviceId); - final var refreshToken = - refreshTokenService - .findByUserIdAndDeviceId(user.getId(), deviceId) - .orElse(refreshTokenService.create(user.getId(), roleDTOs, deviceId)); + final var refreshToken = refreshTokenService.create(user.getId(), roleDTOs, deviceId); deviceService.createOrUpdate(user.getId(), deviceId, userAgent); return new AuthTokens(accessToken, refreshToken); } From ebb94a35cd4dd81ff57ecd81c50e3466c6483635 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 17 Feb 2026 03:04:40 +0100 Subject: [PATCH 5/7] cleanup postgres of inactive devices --- .../org/bugzkit/api/auth/repository/DeviceRepository.java | 3 +++ .../bugzkit/api/auth/service/impl/DeviceServiceImpl.java | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java index 6ec39a4e..befd21f4 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java @@ -1,5 +1,6 @@ package org.bugzkit.api.auth.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.bugzkit.api.auth.model.Device; @@ -13,4 +14,6 @@ public interface DeviceRepository extends JpaRepository { void deleteByUserIdAndDeviceId(Long userId, String deviceId); void deleteAllByUserId(Long userId); + + void deleteByUserIdAndLastActiveAtBefore(Long userId, LocalDateTime cutoff); } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java index 8394f77d..6909858b 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java @@ -11,6 +11,7 @@ import org.bugzkit.api.auth.service.DeviceService; import org.bugzkit.api.auth.util.AuthUtil; import org.bugzkit.api.user.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,9 @@ public class DeviceServiceImpl implements DeviceService { private final AccessTokenService accessTokenService; private final RefreshTokenService refreshTokenService; + @Value("${jwt.refresh-token.duration}") + private int refreshTokenDuration; + public DeviceServiceImpl( DeviceRepository deviceRepository, UserRepository userRepository, @@ -43,6 +47,8 @@ public List findAll(String currentDeviceId) { @Override @Transactional public void createOrUpdate(Long userId, String deviceId, String userAgent) { + deviceRepository.deleteByUserIdAndLastActiveAtBefore( + userId, LocalDateTime.now().minusSeconds(refreshTokenDuration)); final var existing = deviceRepository.findByUserIdAndDeviceId(userId, deviceId); if (existing.isPresent()) { final var device = existing.get(); From b29d0254662a6eef4c86bf3a142c1732a2c53d61 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 17 Feb 2026 12:22:21 +0100 Subject: [PATCH 6/7] rename methods --- .../bugzkit/api/auth/controller/AuthController.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java index a212102c..97fd1554 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java @@ -61,14 +61,14 @@ public ResponseEntity authenticate( final var deviceId = AuthUtil.generateDeviceId(); final var userAgent = request.getHeader("User-Agent"); final var authTokens = authService.authenticate(authTokensRequest, deviceId, userAgent); - return setAuthTokenCookies(authTokens); + return setAuthCookies(authTokens); } @DeleteMapping("/tokens") public ResponseEntity deleteTokens(HttpServletRequest request) { final var accessToken = AuthUtil.getValueFromCookie("accessToken", request); authService.deleteTokens(accessToken); - return removeAuthTokenCookies(); + return removeAuthCookies(); } @GetMapping("/tokens/devices") @@ -81,7 +81,7 @@ public ResponseEntity> findAllDevices(HttpServletRequest request @DeleteMapping("/tokens/devices") public ResponseEntity deleteTokensOnAllDevices() { authService.deleteTokensOnAllDevices(); - return removeAuthTokenCookies(); + return removeAuthCookies(); } @DeleteMapping("/tokens/devices/{deviceId}") @@ -95,7 +95,7 @@ public ResponseEntity refreshTokens(HttpServletRequest request) { final var refreshToken = AuthUtil.getValueFromCookie("refreshToken", request); final var userAgent = request.getHeader("User-Agent"); final var authTokens = authService.refreshTokens(refreshToken, userAgent); - return setAuthTokenCookies(authTokens); + return setAuthCookies(authTokens); } @PostMapping("/password/forgot") @@ -126,7 +126,7 @@ public ResponseEntity verifyEmail( return ResponseEntity.noContent().build(); } - private ResponseEntity setAuthTokenCookies(AuthTokens authTokens) { + private ResponseEntity setAuthCookies(AuthTokens authTokens) { final var accessTokenCookie = AuthUtil.createCookie("accessToken", authTokens.accessToken(), domain, accessTokenDuration); final var refreshTokenCookie = @@ -138,7 +138,7 @@ private ResponseEntity setAuthTokenCookies(AuthTokens authTokens) { .build(); } - private ResponseEntity removeAuthTokenCookies() { + private ResponseEntity removeAuthCookies() { final var accessTokenCookie = AuthUtil.createCookie("accessToken", "", domain, 0); final var refreshTokenCookie = AuthUtil.createCookie("refreshToken", "", domain, 0); return ResponseEntity.noContent() From 1ceb4f7e6831f0b5b68a337fc955bb05aa55d276 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 17 Feb 2026 14:12:23 +0100 Subject: [PATCH 7/7] docs --- docs/src/content/how-it-works/auth.mdx | 62 +++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/src/content/how-it-works/auth.mdx b/docs/src/content/how-it-works/auth.mdx index 7594f25b..355b4a81 100644 --- a/docs/src/content/how-it-works/auth.mdx +++ b/docs/src/content/how-it-works/auth.mdx @@ -7,13 +7,14 @@ When a user signs in, two tokens are created: - **Access Token**: used to access protected resources within the app. - **Refresh Token**: used to silently obtain a new access token when the current one expires, allowing the user to stay signed in without re-entering their password. -Tokens are generated on the backend. When the request is made from SvelteKit server, tokens will be returned in the response body. -Both access and refresh tokens will then be saved in HTTP-only cookie and they will be used to keep the user authenticated. +A unique **device ID** is generated for each sign-in and embedded in both tokens. This device ID is used to track sessions across devices. + +Tokens are set as HTTP-only cookies directly by the backend via `Set-Cookie` headers. The frontend (`hooks.server.ts`) reads and forwards these cookies on subsequent requests. #### Token Storage and Lifetime - **Access Token**: Not stored in a database, valid for 15 minutes. -- **Refresh Token**: Stored in Redis, valid for 7 days. +- **Refresh Token**: Stored in Redis (indexed by user ID and device ID), valid for 7 days. You can change expiration times through environment variables. @@ -26,6 +27,8 @@ In SvelteKit, each API request is intercepted and modified using the `hooks.serv - If the token is invalid, try to refresh it by sending a request to the API with the refresh token from the HTTP-only cookie. - If refreshing fails, the user is considered unauthenticated and redirected if necessary. +When tokens are refreshed, the device's `lastActiveAt` timestamp is updated and the User-Agent is recorded. + Refer to `hooks.server.ts` to see how this is implemented. #### Refresh Token Rotation @@ -38,9 +41,10 @@ For more details, see [Auth0's docs on refresh token rotation](https://auth0.com When a user signs out: -- Tokens are removed from HTTP-only cookies. -- The refresh token is deleted from Redis. +- The refresh token for the current device is deleted from Redis. - The access token is blacklisted in Redis. +- The device record is removed from the database. +- Auth cookies are cleared via `Set-Cookie` headers with a max-age of 0. #### Why Blacklist Access Tokens? @@ -54,9 +58,52 @@ For more details, check [this Stack Overflow discussion](https://stackoverflow.c When a user signs out from all devices: -- All user's refresh tokens will be deleted. +- All refresh tokens for the user are deleted from Redis. - The user's ID and the time of the request are stored in Redis. This is used to ensure that all access tokens created before the sign-out-from-all-devices request are invalid. Again, this data will only be stored in Redis for the access token lifetime (15 minutes). +- All device records for the user are removed from the database. + +### Devices + +Each sign-in creates or updates a **device** record in PostgreSQL. A device stores: + +- **deviceId**: A unique identifier generated at sign-in and embedded in the JWT. +- **userAgent**: The User-Agent header from the request, used to display device info (browser, OS) to the user. +- **createdAt**: When the device was first seen. +- **lastActiveAt**: Updated on every token refresh, used to track activity. + +Users can view and manage their active devices from the profile settings page. Revoking a device will: + +- Delete the device record from PostgreSQL. +- Delete the device's refresh token from Redis. +- Invalidate all of the user's access tokens (since access tokens cannot be selectively revoked by device). + +#### Inactive Device Cleanup + +Devices that have been inactive longer than the refresh token lifetime are automatically cleaned up from the database. This happens during sign-in and token refresh operations, ensuring that stale device records don't accumulate. + +### Google OAuth2 + +Users can sign in with their Google account as an alternative to email/password authentication. The flow uses Spring Security's built-in OAuth2 support. + +#### How It Works + +1. The user clicks "Sign in with Google" on the sign-in page, which navigates them to `/oauth2/authorization/google` on the backend. +2. Spring Security redirects the user to Google's consent screen. +3. After the user grants access, Google redirects back to the backend with an authorization code. +4. Spring Security exchanges the code for user info via `OAuth2UserService`: + - The user's email and `email_verified` status are read from Google's response. + - If the email is not verified by Google, authentication is rejected. + - If a user with that email already exists, they are loaded from the database. + - If no user exists, a new account is created automatically with the `USER` role and marked as active (no email verification needed since Google already verified it). +5. On success, `OAuth2SuccessHandler` generates access and refresh tokens (same as regular sign-in), creates a device record, sets auth cookies via `Set-Cookie` headers, and redirects the user to the frontend. +6. On failure, `OAuth2FailureHandler` redirects to the sign-in page with an `error` query parameter that maps to an i18n error message. + +#### Notes + +- OAuth2 users are created without a password or username. On first sign-in, the user is prompted to set a username on the frontend. +- The same device tracking, token rotation, and cookie-based auth apply to OAuth2 sessions — there is no difference in behavior after the initial sign-in. +- Google OAuth2 requires `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` environment variables to be configured. ### Account Verification @@ -75,6 +122,7 @@ All JWTs share the following structure: - **expireAt**: Timestamp of when the token expires. - **purpose**: Describes the token's purpose (`access_token`, `refresh_token`, `reset_password_token`, `verify_email_token`). -Access and refresh tokens include an additional claim: +Access and refresh tokens include additional claims: - **roles**: Represents the roles assigned to the user. +- **deviceId**: Identifies the device/session the token belongs to.