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..8f739cbe --- /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) {} 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..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 @@ -2,6 +2,10 @@ import jakarta.servlet.http.HttpServletRequest; 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; import org.bugzkit.api.auth.payload.request.RegisterUserRequest; @@ -9,6 +13,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 +22,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 +33,7 @@ @RequestMapping(Path.AUTH) public class AuthController { private final AuthService authService; + private final DeviceService deviceService; @Value("${domain.name}") private String domain; @@ -36,8 +44,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 +58,44 @@ 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.generateDeviceId(); + final var userAgent = request.getHeader("User-Agent"); + final var authTokens = authService.authenticate(authTokensRequest, deviceId, userAgent); + return setAuthCookies(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(); + authService.deleteTokens(accessToken); + return removeAuthCookies(); + } + + @GetMapping("/tokens/devices") + public ResponseEntity> findAllDevices(HttpServletRequest 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 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(); + return removeAuthCookies(); + } + + @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 userAgent = request.getHeader("User-Agent"); + final var authTokens = authService.refreshTokens(refreshToken, userAgent); + return setAuthCookies(authTokens); } @PostMapping("/password/forgot") @@ -131,4 +125,25 @@ public ResponseEntity verifyEmail( authService.verifyEmail(verifyEmailRequest); return ResponseEntity.noContent().build(); } + + private ResponseEntity setAuthCookies(AuthTokens authTokens) { + final var accessTokenCookie = + AuthUtil.createCookie("accessToken", authTokens.accessToken(), domain, accessTokenDuration); + final var refreshTokenCookie = + AuthUtil.createCookie( + "refreshToken", authTokens.refreshToken(), domain, refreshTokenDuration); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + .build(); + } + + private ResponseEntity removeAuthCookies() { + 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(); + } } 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/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/RefreshTokenService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/jwt/service/RefreshTokenService.java index 1715f251..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,19 +1,16 @@ package org.bugzkit.api.auth.jwt.service; -import java.util.Optional; import java.util.Set; 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); - 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/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 fa27db62..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; @@ -30,17 +29,17 @@ 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()) .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)); - refreshTokenStoreRepository.save( - new RefreshTokenStore(token, userId, ipAddress, tokenDuration)); + refreshTokenStoreRepository.save(new RefreshTokenStore(token, userId, deviceId, tokenDuration)); return token; } @@ -63,22 +62,15 @@ private void isInRefreshTokenStore(String token) { throw new BadRequestException("auth.tokenInvalid"); } - @Override - public Optional findByUserIdAndIpAddress(Long userId, String ipAddress) { - return refreshTokenStoreRepository - .findByUserIdAndIpAddress(userId, ipAddress) - .map(RefreshTokenStore::getRefreshToken); - } - @Override public void delete(String token) { refreshTokenStoreRepository.deleteById(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/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/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..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 @@ -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,9 +56,11 @@ public void onAuthenticationSuccess( userPrincipal.getAuthorities().stream() .map(authority -> new RoleDTO(authority.getAuthority())) .collect(Collectors.toSet()); - final var ipAddress = AuthUtil.getUserIpAddress(request); - final var accessToken = accessTokenService.create(userPrincipal.getId(), roleDTOs); - final var refreshToken = refreshTokenService.create(userPrincipal.getId(), roleDTOs, ipAddress); + 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); final var accessTokenCookie = AuthUtil.createCookie("accessToken", accessToken, domain, accessTokenDuration); final var refreshTokenCookie = 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..befd21f4 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/repository/DeviceRepository.java @@ -0,0 +1,19 @@ +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; +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); + + void deleteByUserIdAndLastActiveAtBefore(Long userId, LocalDateTime cutoff); +} 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..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 @@ -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); void deleteTokensOnAllDevices(); - AuthTokensDTO refreshTokens(String refreshToken, String ipAddress); + AuthTokens refreshTokens(String refreshToken, 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..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 @@ -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<>()); @@ -87,12 +92,10 @@ public AuthTokensDTO authenticate(AuthTokensRequest authTokensRequest, String ip .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 refreshToken = - refreshTokenService - .findByUserIdAndIpAddress(user.getId(), ipAddress) - .orElse(refreshTokenService.create(user.getId(), roleDTOs, ipAddress)); - return new AuthTokensDTO(accessToken, refreshToken); + final var accessToken = accessTokenService.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); } private User createUser(RegisterUserRequest registerUserRequest) { @@ -109,30 +112,37 @@ private User createUser(RegisterUserRequest registerUserRequest) { } @Override - public void deleteTokens(String accessToken, String ipAddress) { + @Transactional + public void deleteTokens(String accessToken) { if (!AuthUtil.isSignedIn()) return; final var id = AuthUtil.findSignedInUser().getId(); - refreshTokenService.deleteByUserIdAndIpAddress(id, ipAddress); + final var deviceId = JwtUtil.getDeviceId(accessToken); + 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 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 newRefreshToken = refreshTokenService.create(userId, roleDTOs, ipAddress); - return new AuthTokensDTO(newAccessToken, newRefreshToken); + 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); } @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..6909858b --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/service/impl/DeviceServiceImpl.java @@ -0,0 +1,88 @@ +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.beans.factory.annotation.Value; +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; + + @Value("${jwt.refresh-token.duration}") + private int refreshTokenDuration; + + 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) { + deviceRepository.deleteByUserIdAndLastActiveAtBefore( + userId, LocalDateTime.now().minusSeconds(refreshTokenDuration)); + 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..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 @@ -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,8 @@ 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 generateDeviceId() { + return 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..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 @@ -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; @@ -15,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; @@ -379,4 +385,39 @@ 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()))) + .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"); + final var deviceId = JwtUtil.getDeviceId(authTokens.accessToken()); + mockMvc + .perform( + 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 + final var freshTokens = IntegrationTestUtil.authTokens(mockMvc, objectMapper, "update3"); + mockMvc + .perform( + get(Path.AUTH + "/tokens/devices") + .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/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..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 @@ -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,7 +25,7 @@ public static AuthTokensDTO authTokens( .andExpect(status().isNoContent()) .andReturn() .getResponse(); - return new AuthTokensDTO( + return new AuthTokens( response.getCookie("accessToken").getValue(), response.getCookie("refreshToken").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/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. 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/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 b5824b31..f0394810 100644 --- a/frontend/svelte-kit/src/lib/server/apis/api.ts +++ b/frontend/svelte-kit/src/lib/server/apis/api.ts @@ -14,6 +14,7 @@ interface RequestParams { export async function makeRequest( params: RequestParams, cookies: Cookies, + request?: Request, ): Promise { const opts: RequestInit = {}; const headers = new Headers(); @@ -23,6 +24,9 @@ export async function makeRequest( if (accessToken) headers.append('Cookie', `accessToken=${accessToken}`); if (refreshToken) headers.append('Cookie', `refreshToken=${refreshToken}`); + const userAgent = request?.headers.get('User-Agent'); + if (userAgent) headers.set('User-Agent', userAgent); + if (params.body) { headers.append('Content-Type', 'application/json'); opts.body = params.body; 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(), +});