diff --git a/pom.xml b/pom.xml index 0f5b43be..16b9623d 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,7 @@ 1.19.8 5.4.0 0.10.2 + 2.7.0 @@ -152,6 +153,11 @@ commons-codec commons-codec + + com.yubico + webauthn-server-core + ${webauthn-server.version} + diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index b87af4b3..cc22c810 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -43,6 +43,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers( "/api/v1/auth/register", "/api/v1/auth/login", + "/api/v1/auth/passkey/login/options", + "/api/v1/auth/passkey/login/verify", "/api/v1/auth/refresh", "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password", diff --git a/src/main/java/com/jobtracker/config/WebAuthnConfig.java b/src/main/java/com/jobtracker/config/WebAuthnConfig.java new file mode 100644 index 00000000..d91af088 --- /dev/null +++ b/src/main/java/com/jobtracker/config/WebAuthnConfig.java @@ -0,0 +1,29 @@ +package com.jobtracker.config; + +import com.yubico.webauthn.CredentialRepository; +import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.data.RelyingPartyIdentity; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Set; + +@Configuration +@EnableConfigurationProperties(WebAuthnProperties.class) +public class WebAuthnConfig { + + @Bean + public RelyingParty relyingParty(WebAuthnProperties properties, CredentialRepository credentialRepository) { + RelyingPartyIdentity identity = RelyingPartyIdentity.builder() + .id(properties.rpId()) + .name(properties.rpName()) + .build(); + + return RelyingParty.builder() + .identity(identity) + .credentialRepository(credentialRepository) + .origins(Set.copyOf(properties.origins())) + .build(); + } +} diff --git a/src/main/java/com/jobtracker/config/WebAuthnProperties.java b/src/main/java/com/jobtracker/config/WebAuthnProperties.java new file mode 100644 index 00000000..e3c3f88a --- /dev/null +++ b/src/main/java/com/jobtracker/config/WebAuthnProperties.java @@ -0,0 +1,14 @@ +package com.jobtracker.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "app.webauthn") +public record WebAuthnProperties( + String rpId, + String rpName, + List origins, + long challengeTimeoutSeconds +) { +} diff --git a/src/main/java/com/jobtracker/controller/AuthController.java b/src/main/java/com/jobtracker/controller/AuthController.java index f9c04e87..4a3b1d70 100644 --- a/src/main/java/com/jobtracker/controller/AuthController.java +++ b/src/main/java/com/jobtracker/controller/AuthController.java @@ -14,6 +14,7 @@ import com.jobtracker.dto.auth.UserResponse; import com.jobtracker.mapper.AuthMapper; import com.jobtracker.service.AuthService; +import com.jobtracker.service.PasskeyAuthService; import com.jobtracker.util.SecurityUtils; import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import io.swagger.v3.oas.annotations.Operation; @@ -35,15 +36,17 @@ public class AuthController { private static final String REFRESH_COOKIE_NAME = "refreshToken"; - private static final String REFRESH_COOKIE_PATH = "/api/v1/auth"; + private static final String REFRESH_COOKIE_PATH = "/api/v1/auth/refresh"; private static final long REFRESH_TOKEN_MAX_AGE_SECONDS = 7L * 24 * 60 * 60; private final AuthService authService; + private final PasskeyAuthService passkeyAuthService; private final AuthMapper authMapper; private final SecurityUtils securityUtils; - public AuthController(AuthService authService, AuthMapper authMapper, SecurityUtils securityUtils) { + public AuthController(AuthService authService, PasskeyAuthService passkeyAuthService, AuthMapper authMapper, SecurityUtils securityUtils) { this.authService = authService; + this.passkeyAuthService = passkeyAuthService; this.authMapper = authMapper; this.securityUtils = securityUtils; } @@ -229,4 +232,33 @@ public ResponseEntity updateProfile(@Valid @RequestBody UpdateProf public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest request) { return ResponseEntity.ok(authService.changePassword(request)); } -} \ No newline at end of file + + @PostMapping("/passkey/register/options") + public ResponseEntity passkeyRegisterOptions() { + return ResponseEntity.ok(passkeyAuthService.registerOptions()); + } + + @PostMapping("/passkey/register/verify") + public ResponseEntity passkeyRegisterVerify(@Valid @RequestBody PasskeyVerifyRequest request) { + return ResponseEntity.ok(passkeyAuthService.registerVerify(request)); + } + + @PostMapping("/passkey/login/options") + public ResponseEntity passkeyLoginOptions(@Valid @RequestBody PasskeyLoginOptionsRequest request) { + return ResponseEntity.ok(passkeyAuthService.loginOptions(request)); + } + + @PostMapping("/passkey/login/verify") + public ResponseEntity passkeyLoginVerify(@Valid @RequestBody PasskeyVerifyRequest request, + HttpServletResponse response) { + AuthResponse authResponse = passkeyAuthService.loginVerify(request); + setRefreshTokenCookie(response, authService.getLastRefreshToken()); + return ResponseEntity.ok(authResponse); + } + + @GetMapping("/passkey/me") + public ResponseEntity passkeyMe() { + return ResponseEntity.ok(passkeyAuthService.me()); + } + +} diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyLoginOptionsRequest.java b/src/main/java/com/jobtracker/dto/auth/PasskeyLoginOptionsRequest.java new file mode 100644 index 00000000..5663b47f --- /dev/null +++ b/src/main/java/com/jobtracker/dto/auth/PasskeyLoginOptionsRequest.java @@ -0,0 +1,9 @@ +package com.jobtracker.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record PasskeyLoginOptionsRequest( + @NotBlank @Email String email +) { +} diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java b/src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java new file mode 100644 index 00000000..3517a037 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java @@ -0,0 +1,12 @@ +package com.jobtracker.dto.auth; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.UUID; + +public record PasskeyOptionsResponse( + boolean passkeyAvailable, + UUID challengeId, + JsonNode publicKey +) { +} diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyStatusResponse.java b/src/main/java/com/jobtracker/dto/auth/PasskeyStatusResponse.java new file mode 100644 index 00000000..40f5e867 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/auth/PasskeyStatusResponse.java @@ -0,0 +1,6 @@ +package com.jobtracker.dto.auth; + +public record PasskeyStatusResponse( + boolean hasPasskeys +) { +} diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java b/src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java new file mode 100644 index 00000000..93caaa41 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java @@ -0,0 +1,12 @@ +package com.jobtracker.dto.auth; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +public record PasskeyVerifyRequest( + @NotNull UUID challengeId, + @NotNull JsonNode credential +) { +} diff --git a/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java b/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java new file mode 100644 index 00000000..7e7c0185 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java @@ -0,0 +1,131 @@ +package com.jobtracker.entity; + +import com.jobtracker.entity.enums.WebAuthnChallengeType; +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "webauthn_challenges", indexes = { + @Index(name = "idx_webauthn_challenges_user_id", columnList = "user_id"), + @Index(name = "idx_webauthn_challenges_expires_at", columnList = "expires_at") +}) +public class WebAuthnChallenge { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 32) + private WebAuthnChallengeType type; + + @Lob + @Column(name = "request_json", nullable = false) + private String requestJson; + + @Column(name = "challenge", nullable = false, length = 512) + private String challenge; + + @Column(name = "used", nullable = false) + private boolean used; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public WebAuthnChallengeType getType() { + return type; + } + + public void setType(WebAuthnChallengeType type) { + this.type = type; + } + + public String getRequestJson() { + return requestJson; + } + + public void setRequestJson(String requestJson) { + this.requestJson = requestJson; + } + + public String getChallenge() { + return challenge; + } + + public void setChallenge(String challenge) { + this.challenge = challenge; + } + + public boolean isUsed() { + return used; + } + + public void setUsed(boolean used) { + this.used = used; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/jobtracker/entity/WebAuthnCredential.java b/src/main/java/com/jobtracker/entity/WebAuthnCredential.java new file mode 100644 index 00000000..52b322a1 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/WebAuthnCredential.java @@ -0,0 +1,129 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "webauthn_credentials", indexes = { + @Index(name = "idx_webauthn_credentials_user_id", columnList = "user_id"), + @Index(name = "idx_webauthn_credentials_credential_id", columnList = "credential_id", unique = true) +}) +public class WebAuthnCredential { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "credential_id", nullable = false, unique = true, length = 512) + private String credentialId; + + @Lob + @Column(name = "public_key_cose", nullable = false) + private String publicKeyCose; + + @Column(name = "sign_count", nullable = false) + private long signCount; + + @Column(name = "transports", length = 255) + private String transports; + + @Column(name = "user_handle", nullable = false, length = 255) + private String userHandle; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getCredentialId() { + return credentialId; + } + + public void setCredentialId(String credentialId) { + this.credentialId = credentialId; + } + + public String getPublicKeyCose() { + return publicKeyCose; + } + + public void setPublicKeyCose(String publicKeyCose) { + this.publicKeyCose = publicKeyCose; + } + + public long getSignCount() { + return signCount; + } + + public void setSignCount(long signCount) { + this.signCount = signCount; + } + + public String getTransports() { + return transports; + } + + public void setTransports(String transports) { + this.transports = transports; + } + + public String getUserHandle() { + return userHandle; + } + + public void setUserHandle(String userHandle) { + this.userHandle = userHandle; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/jobtracker/entity/enums/WebAuthnChallengeType.java b/src/main/java/com/jobtracker/entity/enums/WebAuthnChallengeType.java new file mode 100644 index 00000000..fb6005ed --- /dev/null +++ b/src/main/java/com/jobtracker/entity/enums/WebAuthnChallengeType.java @@ -0,0 +1,6 @@ +package com.jobtracker.entity.enums; + +public enum WebAuthnChallengeType { + REGISTRATION, + AUTHENTICATION +} diff --git a/src/main/java/com/jobtracker/repository/WebAuthnChallengeRepository.java b/src/main/java/com/jobtracker/repository/WebAuthnChallengeRepository.java new file mode 100644 index 00000000..7dd54e92 --- /dev/null +++ b/src/main/java/com/jobtracker/repository/WebAuthnChallengeRepository.java @@ -0,0 +1,14 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.WebAuthnChallenge; +import com.jobtracker.entity.enums.WebAuthnChallengeType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +public interface WebAuthnChallengeRepository extends JpaRepository { + Optional findByIdAndTypeAndUsedFalse(UUID id, WebAuthnChallengeType type); + void deleteAllByExpiresAtBefore(LocalDateTime threshold); +} diff --git a/src/main/java/com/jobtracker/repository/WebAuthnCredentialRepository.java b/src/main/java/com/jobtracker/repository/WebAuthnCredentialRepository.java new file mode 100644 index 00000000..8c1f9898 --- /dev/null +++ b/src/main/java/com/jobtracker/repository/WebAuthnCredentialRepository.java @@ -0,0 +1,15 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.User; +import com.jobtracker.entity.WebAuthnCredential; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface WebAuthnCredentialRepository extends JpaRepository { + List findByUser(User user); + long countByUser(User user); + Optional findByCredentialId(String credentialId); +} diff --git a/src/main/java/com/jobtracker/service/AuthService.java b/src/main/java/com/jobtracker/service/AuthService.java index ba7c4f41..231c4e51 100644 --- a/src/main/java/com/jobtracker/service/AuthService.java +++ b/src/main/java/com/jobtracker/service/AuthService.java @@ -125,6 +125,11 @@ public AuthResponse login(LoginRequest request) { } } + @Transactional + public AuthResponse issueAuthTokens(User user) { + return buildAuthResponse(user); + } + @Transactional public RefreshResponse refresh(RefreshTokenRequest request, String refreshToken) { Span span = tracer.nextSpan().name("token-refresh").start(); diff --git a/src/main/java/com/jobtracker/service/JpaWebAuthnCredentialRepository.java b/src/main/java/com/jobtracker/service/JpaWebAuthnCredentialRepository.java new file mode 100644 index 00000000..3a855a32 --- /dev/null +++ b/src/main/java/com/jobtracker/service/JpaWebAuthnCredentialRepository.java @@ -0,0 +1,140 @@ +package com.jobtracker.service; + +import com.jobtracker.entity.User; +import com.jobtracker.entity.WebAuthnCredential; +import com.jobtracker.repository.UserRepository; +import com.jobtracker.repository.WebAuthnCredentialRepository; +import com.yubico.webauthn.CredentialRepository; +import com.yubico.webauthn.RegisteredCredential; +import com.yubico.webauthn.data.AuthenticatorTransport; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import com.yubico.webauthn.data.exception.Base64UrlException; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.stream.Collectors; + +@Component +public class JpaWebAuthnCredentialRepository implements CredentialRepository { + + private final WebAuthnCredentialRepository webAuthnCredentialRepository; + private final UserRepository userRepository; + + public JpaWebAuthnCredentialRepository(WebAuthnCredentialRepository webAuthnCredentialRepository, + UserRepository userRepository) { + this.webAuthnCredentialRepository = webAuthnCredentialRepository; + this.userRepository = userRepository; + } + + @Override + public Set getCredentialIdsForUsername(String username) { + return userRepository.findByEmail(username) + .map(user -> webAuthnCredentialRepository.findByUser(user).stream() + .map(this::toDescriptor) + .collect(Collectors.toSet())) + .orElseGet(Collections::emptySet); + } + + @Override + public Optional getUserHandleForUsername(String username) { + return userRepository.findByEmail(username) + .map(User::getId) + .map(this::uuidToByteArray); + } + + @Override + public Optional getUsernameForUserHandle(ByteArray userHandle) { + return userRepository.findById(byteArrayToUuid(userHandle)) + .map(User::getEmail); + } + + @Override + public Optional lookup(ByteArray credentialId, ByteArray userHandle) { + return webAuthnCredentialRepository.findByCredentialId(credentialId.getBase64Url()) + .filter(stored -> stored.getUserHandle().equals(userHandle.getBase64Url())) + .map(this::toRegisteredCredential); + } + + @Override + public Set lookupAll(ByteArray credentialId) { + return webAuthnCredentialRepository.findByCredentialId(credentialId.getBase64Url()) + .map(stored -> Set.of(toRegisteredCredential(stored))) + .orElseGet(Collections::emptySet); + } + + private PublicKeyCredentialDescriptor toDescriptor(WebAuthnCredential credential) { + PublicKeyCredentialDescriptor.PublicKeyCredentialDescriptorBuilder builder = PublicKeyCredentialDescriptor.builder() + .id(parseBase64Url(credential.getCredentialId())); + parseTransports(credential.getTransports()).ifPresent(builder::transports); + return builder.build(); + } + + private Optional> parseTransports(String transports) { + if (transports == null || transports.isBlank()) { + return Optional.empty(); + } + SortedSet parsed = Arrays.stream(transports.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .map(this::toTransportOrNull) + .filter(value -> value != null) + .collect(Collectors.toCollection(TreeSet::new)); + return parsed.isEmpty() ? Optional.empty() : Optional.of(parsed); + } + + private AuthenticatorTransport toTransportOrNull(String value) { + try { + return AuthenticatorTransport.of(value); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private RegisteredCredential toRegisteredCredential(WebAuthnCredential credential) { + return RegisteredCredential.builder() + .credentialId(parseBase64Url(credential.getCredentialId())) + .userHandle(parseBase64Url(credential.getUserHandle())) + .publicKeyCose(parseBase64Url(credential.getPublicKeyCose())) + .signatureCount(credential.getSignCount()) + .build(); + } + + private ByteArray parseBase64Url(String value) { + try { + return ByteArray.fromBase64Url(value); + } catch (Base64UrlException e) { + throw new IllegalArgumentException("Invalid stored WebAuthn credential encoding", e); + } + } + + private ByteArray uuidToByteArray(java.util.UUID uuid) { + byte[] bytes = new byte[16]; + long mostSignificant = uuid.getMostSignificantBits(); + long leastSignificant = uuid.getLeastSignificantBits(); + for (int i = 0; i < 8; i++) { + bytes[i] = (byte) (mostSignificant >>> (56 - (i * 8))); + bytes[8 + i] = (byte) (leastSignificant >>> (56 - (i * 8))); + } + return new ByteArray(bytes); + } + + private java.util.UUID byteArrayToUuid(ByteArray userHandle) { + byte[] bytes = userHandle.getBytes(); + if (bytes.length != 16) { + throw new IllegalArgumentException("Invalid user handle length"); + } + long mostSignificant = 0; + long leastSignificant = 0; + for (int i = 0; i < 8; i++) { + mostSignificant = (mostSignificant << 8) | (bytes[i] & 0xff); + leastSignificant = (leastSignificant << 8) | (bytes[8 + i] & 0xff); + } + return new java.util.UUID(mostSignificant, leastSignificant); + } +} diff --git a/src/main/java/com/jobtracker/service/PasskeyAuthService.java b/src/main/java/com/jobtracker/service/PasskeyAuthService.java new file mode 100644 index 00000000..67411d77 --- /dev/null +++ b/src/main/java/com/jobtracker/service/PasskeyAuthService.java @@ -0,0 +1,318 @@ +package com.jobtracker.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobtracker.config.WebAuthnProperties; +import com.jobtracker.dto.auth.AuthResponse; +import com.jobtracker.dto.auth.MessageResponse; +import com.jobtracker.dto.auth.PasskeyLoginOptionsRequest; +import com.jobtracker.dto.auth.PasskeyOptionsResponse; +import com.jobtracker.dto.auth.PasskeyStatusResponse; +import com.jobtracker.dto.auth.PasskeyVerifyRequest; +import com.jobtracker.entity.User; +import com.jobtracker.entity.WebAuthnChallenge; +import com.jobtracker.entity.WebAuthnCredential; +import com.jobtracker.entity.enums.WebAuthnChallengeType; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.UnauthorizedException; +import com.jobtracker.repository.UserRepository; +import com.jobtracker.repository.WebAuthnChallengeRepository; +import com.jobtracker.repository.WebAuthnCredentialRepository; +import com.jobtracker.util.SecurityUtils; +import com.yubico.webauthn.AssertionRequest; +import com.yubico.webauthn.AssertionResult; +import com.yubico.webauthn.FinishAssertionOptions; +import com.yubico.webauthn.FinishRegistrationOptions; +import com.yubico.webauthn.RegistrationResult; +import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.StartAssertionOptions; +import com.yubico.webauthn.StartRegistrationOptions; +import com.yubico.webauthn.data.AuthenticatorAssertionResponse; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; +import com.yubico.webauthn.data.AuthenticatorTransport; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; +import com.yubico.webauthn.data.PublicKeyCredential; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; +import com.yubico.webauthn.data.UserIdentity; +import com.yubico.webauthn.exception.AssertionFailedException; +import com.yubico.webauthn.exception.RegistrationFailedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class PasskeyAuthService { + + private final RelyingParty relyingParty; + private final ObjectMapper objectMapper; + private final WebAuthnProperties webAuthnProperties; + private final WebAuthnChallengeRepository webAuthnChallengeRepository; + private final WebAuthnCredentialRepository webAuthnCredentialRepository; + private final SecurityUtils securityUtils; + private final AuthService authService; + private final UserRepository userRepository; + + public PasskeyAuthService(RelyingParty relyingParty, + ObjectMapper objectMapper, + WebAuthnProperties webAuthnProperties, + WebAuthnChallengeRepository webAuthnChallengeRepository, + WebAuthnCredentialRepository webAuthnCredentialRepository, + SecurityUtils securityUtils, + AuthService authService, + UserRepository userRepository) { + this.relyingParty = relyingParty; + this.objectMapper = objectMapper; + this.webAuthnProperties = webAuthnProperties; + this.webAuthnChallengeRepository = webAuthnChallengeRepository; + this.webAuthnCredentialRepository = webAuthnCredentialRepository; + this.securityUtils = securityUtils; + this.authService = authService; + this.userRepository = userRepository; + } + + @Transactional + public PasskeyOptionsResponse registerOptions() { + User user = securityUtils.getCurrentUser(); + UserIdentity userIdentity = UserIdentity.builder() + .name(user.getEmail()) + .displayName(user.getName()) + .id(uuidToByteArray(user.getId())) + .build(); + + PublicKeyCredentialCreationOptions options = relyingParty.startRegistration( + StartRegistrationOptions.builder() + .user(userIdentity) + .timeout(webAuthnProperties.challengeTimeoutSeconds() * 1000L) + .build() + ); + + WebAuthnChallenge challenge = persistChallenge( + user, + WebAuthnChallengeType.REGISTRATION, + toJsonSafely(options), + options.getChallenge().getBase64Url() + ); + + return new PasskeyOptionsResponse(true, challenge.getId(), readJson(toCredentialsCreateJsonSafely(options))); + } + + @Transactional + public MessageResponse registerVerify(PasskeyVerifyRequest request) { + User user = securityUtils.getCurrentUser(); + WebAuthnChallenge challenge = resolveUsableChallenge(request.challengeId(), WebAuthnChallengeType.REGISTRATION, user); + PublicKeyCredentialCreationOptions options = parseRegistrationOptions(challenge.getRequestJson()); + PublicKeyCredential parsedCredential = + parseRegistrationCredential(request.credential()); + + try { + RegistrationResult result = relyingParty.finishRegistration( + FinishRegistrationOptions.builder() + .request(options) + .response(parsedCredential) + .build() + ); + + String credentialId = result.getKeyId().getId().getBase64Url(); + if (webAuthnCredentialRepository.findByCredentialId(credentialId).isPresent()) { + throw new BadRequestException("Passkey already registered"); + } + + WebAuthnCredential credential = new WebAuthnCredential(); + credential.setUser(user); + credential.setCredentialId(credentialId); + credential.setPublicKeyCose(result.getPublicKeyCose().getBase64Url()); + credential.setSignCount(result.getSignatureCount()); + credential.setTransports(parsedCredential.getResponse().getTransports().stream() + .map(AuthenticatorTransport::getId) + .collect(Collectors.joining(","))); + credential.setUserHandle(options.getUser().getId().getBase64Url()); + webAuthnCredentialRepository.save(credential); + consumeChallenge(challenge); + return new MessageResponse("Passkey registered successfully"); + } catch (RegistrationFailedException e) { + throw new BadRequestException("Passkey registration verification failed"); + } + } + + @Transactional + public PasskeyOptionsResponse loginOptions(PasskeyLoginOptionsRequest request) { + User user = userRepository.findByEmail(request.email()).orElse(null); + if (user == null || webAuthnCredentialRepository.countByUser(user) == 0) { + return new PasskeyOptionsResponse(false, null, null); + } + + AssertionRequest assertionRequest = relyingParty.startAssertion( + StartAssertionOptions.builder() + .username(user.getEmail()) + .timeout(webAuthnProperties.challengeTimeoutSeconds() * 1000L) + .build() + ); + + WebAuthnChallenge challenge = persistChallenge( + user, + WebAuthnChallengeType.AUTHENTICATION, + toJsonSafely(assertionRequest), + assertionRequest.getPublicKeyCredentialRequestOptions().getChallenge().getBase64Url() + ); + + return new PasskeyOptionsResponse(true, challenge.getId(), readJson(toCredentialsGetJsonSafely(assertionRequest))); + } + + @Transactional + public AuthResponse loginVerify(PasskeyVerifyRequest request) { + WebAuthnChallenge challenge = resolveUsableChallenge(request.challengeId(), WebAuthnChallengeType.AUTHENTICATION, null); + AssertionRequest assertionRequest = parseAssertionRequest(challenge.getRequestJson()); + PublicKeyCredential parsedCredential = + parseAssertionCredential(request.credential()); + + try { + AssertionResult assertionResult = relyingParty.finishAssertion( + FinishAssertionOptions.builder() + .request(assertionRequest) + .response(parsedCredential) + .build() + ); + + if (!assertionResult.isSuccess() || !assertionResult.isSignatureCounterValid()) { + throw new UnauthorizedException("Invalid passkey assertion"); + } + + User user = userRepository.findByEmail(assertionResult.getUsername()) + .orElseThrow(() -> new UnauthorizedException("User not found")); + WebAuthnCredential credential = webAuthnCredentialRepository + .findByCredentialId(assertionResult.getCredentialId().getBase64Url()) + .orElseThrow(() -> new UnauthorizedException("Passkey not registered")); + credential.setSignCount(assertionResult.getSignatureCount()); + webAuthnCredentialRepository.save(credential); + consumeChallenge(challenge); + return authService.issueAuthTokens(user); + } catch (AssertionFailedException e) { + throw new UnauthorizedException("Invalid passkey assertion"); + } + } + + @Transactional(readOnly = true) + public PasskeyStatusResponse me() { + User user = securityUtils.getCurrentUser(); + return new PasskeyStatusResponse(webAuthnCredentialRepository.countByUser(user) > 0); + } + + private WebAuthnChallenge persistChallenge(User user, WebAuthnChallengeType type, String requestJson, String challengeValue) { + webAuthnChallengeRepository.deleteAllByExpiresAtBefore(LocalDateTime.now()); + WebAuthnChallenge challenge = new WebAuthnChallenge(); + challenge.setUser(user); + challenge.setType(type); + challenge.setRequestJson(requestJson); + challenge.setChallenge(challengeValue); + challenge.setUsed(false); + challenge.setExpiresAt(LocalDateTime.now().plusSeconds(webAuthnProperties.challengeTimeoutSeconds())); + return webAuthnChallengeRepository.save(challenge); + } + + private void consumeChallenge(WebAuthnChallenge challenge) { + challenge.setUsed(true); + webAuthnChallengeRepository.save(challenge); + } + + private WebAuthnChallenge resolveUsableChallenge(UUID challengeId, WebAuthnChallengeType type, User expectedUser) { + WebAuthnChallenge challenge = webAuthnChallengeRepository.findByIdAndTypeAndUsedFalse(challengeId, type) + .orElseThrow(() -> new BadRequestException("Invalid or already used challenge")); + if (challenge.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new BadRequestException("Challenge has expired"); + } + if (expectedUser != null && (challenge.getUser() == null || !expectedUser.getId().equals(challenge.getUser().getId()))) { + throw new UnauthorizedException("Challenge does not belong to authenticated user"); + } + return challenge; + } + + private JsonNode readJson(String json) { + try { + return objectMapper.readTree(json); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize WebAuthn options", e); + } + } + + private String toJsonSafely(PublicKeyCredentialCreationOptions options) { + try { + return options.toJson(); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize WebAuthn registration request", e); + } + } + + private String toJsonSafely(AssertionRequest assertionRequest) { + try { + return assertionRequest.toJson(); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize WebAuthn assertion request", e); + } + } + + private String toCredentialsCreateJsonSafely(PublicKeyCredentialCreationOptions options) { + try { + return options.toCredentialsCreateJson(); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize WebAuthn registration options", e); + } + } + + private String toCredentialsGetJsonSafely(AssertionRequest assertionRequest) { + try { + return assertionRequest.toCredentialsGetJson(); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize WebAuthn assertion options", e); + } + } + + private PublicKeyCredentialCreationOptions parseRegistrationOptions(String json) { + try { + return PublicKeyCredentialCreationOptions.fromJson(json); + } catch (JsonProcessingException e) { + throw new BadRequestException("Invalid registration request payload"); + } + } + + private AssertionRequest parseAssertionRequest(String json) { + try { + return AssertionRequest.fromJson(json); + } catch (JsonProcessingException e) { + throw new BadRequestException("Invalid authentication request payload"); + } + } + + private PublicKeyCredential parseRegistrationCredential(JsonNode credential) { + try { + return PublicKeyCredential.parseRegistrationResponseJson(objectMapper.writeValueAsString(credential)); + } catch (IOException e) { + throw new BadRequestException("Invalid registration credential payload"); + } + } + + private PublicKeyCredential parseAssertionCredential(JsonNode credential) { + try { + return PublicKeyCredential.parseAssertionResponseJson(objectMapper.writeValueAsString(credential)); + } catch (IOException e) { + throw new BadRequestException("Invalid authentication credential payload"); + } + } + + private ByteArray uuidToByteArray(UUID uuid) { + byte[] bytes = new byte[16]; + long mostSignificant = uuid.getMostSignificantBits(); + long leastSignificant = uuid.getLeastSignificantBits(); + for (int i = 0; i < 8; i++) { + bytes[i] = (byte) (mostSignificant >>> (56 - (i * 8))); + bytes[8 + i] = (byte) (leastSignificant >>> (56 - (i * 8))); + } + return new ByteArray(bytes); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9b96c618..701ee28e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -76,6 +76,14 @@ app: oauth-complete-url: ${GOOGLE_DRIVE_OAUTH_COMPLETE_URL:} authorization-uri: ${GOOGLE_DRIVE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth} token-uri: ${GOOGLE_DRIVE_TOKEN_URI:https://oauth2.googleapis.com/token} + webauthn: + rp-id: ${APP_WEBAUTHN_RP_ID:localhost} + rp-name: ${APP_WEBAUTHN_RP_NAME:JobApplyTracker} + origins: + - ${APP_WEBAUTHN_ORIGIN_1:http://localhost:3000} + - ${APP_WEBAUTHN_ORIGIN_2:http://localhost:5173} + - ${APP_WEBAUTHN_ORIGIN_3:https://jobapply-web.hugojava.dev} + challenge-timeout-seconds: ${APP_WEBAUTHN_CHALLENGE_TIMEOUT_SECONDS:300} management: server: diff --git a/src/main/resources/db/migration/V16__add_webauthn_passkeys.sql b/src/main/resources/db/migration/V16__add_webauthn_passkeys.sql new file mode 100644 index 00000000..f4e831b1 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_webauthn_passkeys.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id BINARY(16) NOT NULL PRIMARY KEY, + user_id BINARY(16) NOT NULL, + credential_id VARCHAR(512) NOT NULL, + public_key_cose TEXT NOT NULL, + sign_count BIGINT NOT NULL DEFAULT 0, + transports VARCHAR(255), + user_handle VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT uk_webauthn_credential_id UNIQUE (credential_id), + INDEX idx_webauthn_credentials_user_id (user_id), + INDEX idx_webauthn_credentials_credential_id (credential_id), + CONSTRAINT fk_webauthn_credentials_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id BINARY(16) NOT NULL PRIMARY KEY, + user_id BINARY(16), + type VARCHAR(32) NOT NULL, + request_json TEXT NOT NULL, + challenge VARCHAR(512) NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_webauthn_challenges_user_id (user_id), + INDEX idx_webauthn_challenges_expires_at (expires_at), + CONSTRAINT fk_webauthn_challenges_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/test/java/com/jobtracker/integration/AuthControllerIT.java b/src/test/java/com/jobtracker/integration/AuthControllerIT.java index c87cb66f..8a66f4b8 100644 --- a/src/test/java/com/jobtracker/integration/AuthControllerIT.java +++ b/src/test/java/com/jobtracker/integration/AuthControllerIT.java @@ -14,6 +14,8 @@ import com.jobtracker.repository.UserGamificationRepository; import com.jobtracker.repository.UserInterviewMetricsRepository; import com.jobtracker.repository.UserRepository; +import com.jobtracker.repository.WebAuthnChallengeRepository; +import com.jobtracker.repository.WebAuthnCredentialRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -46,6 +48,8 @@ class AuthControllerIT extends AbstractIntegrationTest { @Autowired private UserGamificationRepository userGamificationRepository; @Autowired private UserAchievementRepository userAchievementRepository; @Autowired private UserInterviewMetricsRepository userInterviewMetricsRepository; + @Autowired private WebAuthnChallengeRepository webAuthnChallengeRepository; + @Autowired private WebAuthnCredentialRepository webAuthnCredentialRepository; @BeforeEach void cleanDb() { @@ -56,6 +60,8 @@ void cleanDb() { applicationRepository.deleteAll(); passwordResetTokenRepository.deleteAll(); refreshTokenRepository.deleteAll(); + webAuthnChallengeRepository.deleteAll(); + webAuthnCredentialRepository.deleteAll(); userInterviewMetricsRepository.deleteAll(); userRepository.deleteAll(); } @@ -374,4 +380,44 @@ void changePassword_shouldReturn400_whenCurrentPasswordIsInvalid() throws Except .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("Current password is incorrect")); } + + @Test + void passkeyLoginOptions_shouldReturnFallbackWhenUserHasNoPasskeys() throws Exception { + RegisterRequest reg = new RegisterRequest("No Passkey User", "no-passkey@example.com", "pass1234", "pass1234"); + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reg))) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/v1/auth/passkey/login/options") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"no-passkey@example.com\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.passkeyAvailable").value(false)) + .andExpect(jsonPath("$.challengeId").doesNotExist()) + .andExpect(jsonPath("$.publicKey").doesNotExist()); + } + + @Test + void passkeyRegisterOptions_shouldReturn403WhenNotAuthenticated() throws Exception { + mockMvc.perform(post("/api/v1/auth/passkey/register/options") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void passkeyMe_shouldReturnHasPasskeysFalseByDefault() throws Exception { + RegisterRequest reg = new RegisterRequest("Passkey Status User", "passkey-status@example.com", "pass1234", "pass1234"); + MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reg))) + .andReturn(); + + AuthResponse auth = objectMapper.readValue(regResult.getResponse().getContentAsString(), AuthResponse.class); + + mockMvc.perform(get("/api/v1/auth/passkey/me") + .header("Authorization", "Bearer " + auth.accessToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasPasskeys").value(false)); + } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 2bc77955..3be6959b 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -36,6 +36,13 @@ app: client-secret: test-google-client-secret redirect-uri: http://localhost:8080/api/v1/google-drive/oauth/callback oauth-complete-url: http://localhost:5173/settings/google-drive/callback + webauthn: + rp-id: localhost + rp-name: JobApplyTracker Test + origins: + - http://localhost:3000 + - http://localhost:5173 + challenge-timeout-seconds: 300 cors: allowed-origins: http://localhost:3000