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
2 changes: 2 additions & 0 deletions backend/src/docs/api/auth-api.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Auth API Endpoints

> **Note:** All login and token refresh endpoints now return `accessToken` and `refreshToken` both in the JSON payload and as `HttpOnly` cookies. Web clients should rely on cookies (by setting `credentials: 'include'`) while mobile clients should store tokens from the JSON response securely.

Base path: `/api/v1/auth`

| Method | Endpoint | Description |
Expand Down
111 changes: 96 additions & 15 deletions backend/src/main/java/com/swipelab/auth/api/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.Cookie;

@RestController
@RequestMapping("/api/v1/auth")
Expand All @@ -40,12 +43,46 @@ public class AuthController {
private final com.swipelab.auth.application.JwtService jwtService;
private final SpringTemplateEngine templateEngine;

private void setAuthCookies(HttpServletResponse response, String accessToken, String refreshToken) {
Cookie accessCookie = new Cookie("accessToken", accessToken);
accessCookie.setHttpOnly(true);
accessCookie.setPath("/");
accessCookie.setMaxAge(3600); // 1 hour

Cookie refreshCookie = new Cookie("refreshToken", refreshToken);
refreshCookie.setHttpOnly(true);
refreshCookie.setPath("/");
refreshCookie.setMaxAge(604800); // 7 days

response.addCookie(accessCookie);
response.addCookie(refreshCookie);
}

private void clearAuthCookies(HttpServletResponse response) {
Cookie accessCookie = new Cookie("accessToken", "");
accessCookie.setHttpOnly(true);
accessCookie.setPath("/");
accessCookie.setMaxAge(0);

Cookie refreshCookie = new Cookie("refreshToken", "");
refreshCookie.setHttpOnly(true);
refreshCookie.setPath("/");
refreshCookie.setMaxAge(0);

response.addCookie(accessCookie);
response.addCookie(refreshCookie);
}

/**
* Register a new user
*/
@PostMapping("/register")
public ResponseEntity<java.util.Map<String, Object>> register(@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(authenticationService.register(request));
public ResponseEntity<java.util.Map<String, Object>> register(@Valid @RequestBody RegisterRequest request, HttpServletResponse response) {
java.util.Map<String, Object> result = authenticationService.register(request);
if (result.containsKey("accessToken") && result.containsKey("refreshToken")) {
setAuthCookies(response, (String) result.get("accessToken"), (String) result.get("refreshToken"));
}
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}

/**
Expand Down Expand Up @@ -140,33 +177,58 @@ public ResponseEntity<?> testEndpoint() {

@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authenticationService.login(request));
@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
AuthResponse authResponse = authenticationService.login(request);
setAuthCookies(response, authResponse.getAccessToken(), authResponse.getRefreshToken());
return ResponseEntity.ok(authResponse);
}

@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refreshToken(
@RequestHeader("Authorization") String authorizationHeader) {
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
HttpServletRequest request, HttpServletResponse response) {

String refreshToken = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
refreshToken = authorizationHeader.substring(7);
} else if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("refreshToken".equals(cookie.getName())) {
refreshToken = cookie.getValue();
}
}
}

if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
if (refreshToken == null) {
throw new UnauthorizedException("Missing refresh token");
}

String refreshToken = authorizationHeader.substring(7);
return ResponseEntity.ok(authenticationService.refresh(refreshToken));
AuthResponse authResponse = authenticationService.refresh(refreshToken);
setAuthCookies(response, authResponse.getAccessToken(), authResponse.getRefreshToken());
return ResponseEntity.ok(authResponse);
}

@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestHeader("Authorization") String authorizationHeader) {

if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing refresh token");
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
HttpServletRequest request, HttpServletResponse response) {

String refreshToken = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
refreshToken = authorizationHeader.substring(7);
} else if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("refreshToken".equals(cookie.getName())) {
refreshToken = cookie.getValue();
}
}
}

String refreshToken = authorizationHeader.substring(7);
authenticationService.logout(refreshToken);
if (refreshToken != null) {
authenticationService.logout(refreshToken);
}

clearAuthCookies(response);
return ResponseEntity.noContent().build();
}

Expand All @@ -182,7 +244,7 @@ public ResponseEntity<UserProfileResponse> me(Principal principal) {
}

@PostMapping("/login/google")
public ResponseEntity<AuthResponse> loginGoogle(@RequestBody Map<String, String> payload) {
public ResponseEntity<AuthResponse> loginGoogle(@RequestBody Map<String, String> payload, HttpServletResponse response) {
String credential = payload.get("credential");
if (credential == null) {
credential = payload.get("idToken");
Expand Down Expand Up @@ -227,6 +289,7 @@ public ResponseEntity<AuthResponse> loginGoogle(@RequestBody Map<String, String>
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);

setAuthCookies(response, accessToken, refreshToken);
return ResponseEntity.ok(authMapper.toAuthResponse(accessToken, refreshToken, user));
}

Expand Down Expand Up @@ -272,6 +335,24 @@ public ResponseEntity<Map<String, String>> resetPassword(
return ResponseEntity.ok(response);
}

/**
* Change password for the currently authenticated user
*
* Endpoint: POST /api/v1/auth/password/change
*/
@PostMapping("/password/change")
public ResponseEntity<Map<String, String>> changePassword(
@Valid @RequestBody ChangePasswordRequest request) {

authenticationService.changePassword(request);

Map<String, String> response = new HashMap<>();
response.put("message", "Password changed successfully.");
response.put("status", "success");

return ResponseEntity.ok(response);
}

/**
* Send an invitation email to a new admin or researcher.
* Restricted to the Super Admin — uses the same SpEL bean check
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ public void forgotPassword(String email) {
// Look up user by email
User user = userRepository.findByEmail(email).orElse(null);

// If user exists, generate token and send email
if (user != null) {
// If user exists and is a LOCAL user, generate token and send email
if (user != null && user.getProvider() == com.swipelab.auth.infrastructure.AuthProvider.LOCAL) {
// Generate password reset token
String resetToken = UUID.randomUUID().toString();

Expand Down Expand Up @@ -316,6 +316,32 @@ public void inviteAdmin(com.swipelab.auth.dto.InviteAdminRequest request) {
emailService.sendInvitationEmail(user.getEmail(), request.getRole().name(), invitationToken);
}

/**
* Changes password for a currently authenticated local user.
*/
@Transactional
public void changePassword(com.swipelab.auth.dto.ChangePasswordRequest request) {
org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new UnauthorizedException("User not authenticated");
}

String username = authentication.getName();
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UnauthorizedException("Authenticated user not found"));

if (user.getProvider() != com.swipelab.auth.infrastructure.AuthProvider.LOCAL) {
throw new IllegalArgumentException("External users cannot change their password");
}

String hashedPassword = passwordEncoder.encode(request.getNewPassword());
user.setPasswordHash(hashedPassword);

// Invalidate all refresh tokens for security
user.setRefreshTokenHash(null);

userRepository.save(user);
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public UserProfileResponse toUserProfileResponse(User user) {
.rank(user.getRank() != null ? user.getRank() : "UNRANKED")
.isSuperAdmin(securityAuthorizationService.isSuperAdmin(user.getUsername()))
.active(user.getActive() != null ? user.getActive() : true)
.provider(user.getProvider() != null ? user.getProvider().name() : null)
// Credibility composite score — 0 (bad) to 100 (perfect), default 50 for new users
.credibilityScore(user.getCredibilityScore())
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.swipelab.auth.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ChangePasswordRequest {

@NotBlank(message = "New password is required")
@Size(min = 6, message = "Password must be at least 6 characters")
private String newPassword;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,16 @@

import com.swipelab.auth.dto.AuthResponse;
import com.swipelab.auth.domain.AuthMapper;
import com.swipelab.auth.dto.AuthResponse;
import com.swipelab.auth.dto.ExternalLoginRequest;
import com.swipelab.auth.dto.AuthResponse;
import com.swipelab.users.dto.UserProfileResponse;
import com.swipelab.auth.dto.AuthResponse;
import com.swipelab.users.domain.User;
import com.swipelab.auth.dto.AuthResponse;
import jakarta.validation.Valid;
import com.swipelab.auth.dto.AuthResponse;
import lombok.RequiredArgsConstructor;
import com.swipelab.auth.dto.AuthResponse;
import org.springframework.http.ResponseEntity;
import com.swipelab.auth.dto.AuthResponse;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.Cookie;

@RestController
@RequestMapping("/api/v1/auth/external")
Expand All @@ -26,6 +22,21 @@ public class ExternalAuthController {
private final AuthMapper authMapper;
private final com.swipelab.auth.application.JwtService jwtService;

private void setAuthCookies(HttpServletResponse response, String accessToken, String refreshToken) {
Cookie accessCookie = new Cookie("accessToken", accessToken);
accessCookie.setHttpOnly(true);
accessCookie.setPath("/");
accessCookie.setMaxAge(3600); // 1 hour

Cookie refreshCookie = new Cookie("refreshToken", refreshToken);
refreshCookie.setHttpOnly(true);
refreshCookie.setPath("/");
refreshCookie.setMaxAge(604800); // 7 days

response.addCookie(accessCookie);
response.addCookie(refreshCookie);
}

/**
* Called by the frontend immediately after a successful Stardbi login.
* Validates the Stardbi access token, auto-provisions a local SwipeLab
Expand All @@ -36,7 +47,7 @@ public class ExternalAuthController {
*/
@PostMapping("/stardbi/loginExternal")
public ResponseEntity<com.swipelab.auth.dto.AuthResponse> loginExternal(
@Valid @RequestBody ExternalLoginRequest request) {
@Valid @RequestBody ExternalLoginRequest request, HttpServletResponse httpResponse) {

User user = stardbiAuthService.loginExternal(request);
if (user != null) {
Expand All @@ -53,6 +64,7 @@ public ResponseEntity<com.swipelab.auth.dto.AuthResponse> loginExternal(
.user(profile)
.build();

setAuthCookies(httpResponse, accessToken, refreshToken);
return ResponseEntity.ok(response);
}
return ResponseEntity.status(401).build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ private String getJwtFromRequest(HttpServletRequest request) {
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}

// Support for HttpOnly cookies
if (request.getCookies() != null) {
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/favicon.ico",
// Auth endpoints (strictly matched)
"/api/v1/auth/login",
"/api/v1/auth/logout",
"/api/v1/auth/register",
"/api/v1/auth/refresh",
"/api/v1/auth/password/forgot",
Expand Down Expand Up @@ -135,9 +136,9 @@ public CorsConfigurationSource corsConfigurationSource() {
} else {
configuration.setAllowedOrigins(List.of("*")); // Fallback
}
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(false);
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ public void updateEntity(Task task, UpdateTaskRequest request) {
if (request.getSharedWithResearchers() != null) {
task.setSharedWithResearchers(request.getSharedWithResearchers());
}

if (request.getConsensusThreshold() != null) {
task.setConsensusThreshold(request.getConsensusThreshold());
}
// targetSpecies handled in service
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class UpdateTaskRequest {
private List<String> assignedUsernames;
private List<String> sharedWithResearchers;
private Boolean isPublic;
private Double consensusThreshold;

/**
* Map of Species Name -> List of SpeciesReferenceImage IDs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class UserProfileResponse {
private String displayName;
private String profileImageUrl;
private UserRole role;
private String provider;

// Gamification data
private Long score;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public interface AdminNotificationRepository extends JpaRepository<AdminNotifica
long countByIsRead(Boolean isRead);

/** Mark all as read in a single UPDATE to avoid N+1 queries. */
@Modifying
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE AdminNotification n SET n.isRead = true WHERE n.isRead = false")
int markAllAsRead();
}
Loading
Loading