Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<testcontainers.version>1.19.8</testcontainers.version>
<rest-assured.version>5.4.0</rest-assured.version>
<native.maven.plugin.version>0.10.2</native.maven.plugin.version>
<webauthn-server.version>2.7.0</webauthn-server.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -152,6 +153,11 @@
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>com.yubico</groupId>
<artifactId>webauthn-server-core</artifactId>
<version>${webauthn-server.version}</version>
</dependency>

<!-- Spring OAuth2 Client (token exchange infrastructure) -->
<dependency>
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/jobtracker/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/com/jobtracker/config/WebAuthnConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/jobtracker/config/WebAuthnProperties.java
Original file line number Diff line number Diff line change
@@ -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<String> origins,
long challengeTimeoutSeconds
) {
}
38 changes: 35 additions & 3 deletions src/main/java/com/jobtracker/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -229,4 +232,33 @@ public ResponseEntity<UserResponse> updateProfile(@Valid @RequestBody UpdateProf
public ResponseEntity<MessageResponse> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
return ResponseEntity.ok(authService.changePassword(request));
}
}

@PostMapping("/passkey/register/options")
public ResponseEntity<PasskeyOptionsResponse> passkeyRegisterOptions() {
return ResponseEntity.ok(passkeyAuthService.registerOptions());
}

@PostMapping("/passkey/register/verify")
public ResponseEntity<MessageResponse> passkeyRegisterVerify(@Valid @RequestBody PasskeyVerifyRequest request) {
return ResponseEntity.ok(passkeyAuthService.registerVerify(request));
}

@PostMapping("/passkey/login/options")
public ResponseEntity<PasskeyOptionsResponse> passkeyLoginOptions(@Valid @RequestBody PasskeyLoginOptionsRequest request) {
return ResponseEntity.ok(passkeyAuthService.loginOptions(request));
}

@PostMapping("/passkey/login/verify")
public ResponseEntity<AuthResponse> 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<PasskeyStatusResponse> passkeyMe() {
return ResponseEntity.ok(passkeyAuthService.me());
}

}
Original file line number Diff line number Diff line change
@@ -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
) {
}
12 changes: 12 additions & 0 deletions src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.jobtracker.dto.auth;

public record PasskeyStatusResponse(
boolean hasPasskeys
) {
}
12 changes: 12 additions & 0 deletions src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
131 changes: 131 additions & 0 deletions src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading