Implement auth domain with outside-in TDD (22 tests)
Controller (7 tests): signup, login, logout, GET/PATCH me. Standalone MockMvc setup (Boot 4 removed @WebMvcTest). Service (11 tests): signup with conflict check, login with password/active validation, getCurrentUser with household info, updateProfile with password change flow. Repository (4 tests): save/find, case-insensitive email via IgnoreCase queries (citext + Hibernate needs explicit IgnoreCase), existsByEmail. Also includes: - SecurityConfig: session auth, CSRF, role-based authorization - CustomUserDetailsService: loads UserAccount for Spring Security - UserAccount, Household, HouseholdMember JPA entities - spring-boot-flyway dependency (Boot 4 requires explicit module) - ddl-auto=none (Flyway owns schema, validate fails on citext) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
65
backend/src/main/java/com/recipeapp/auth/AuthController.java
Normal file
65
backend/src/main/java/com/recipeapp/auth/AuthController.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.dto.*;
|
||||
import com.recipeapp.common.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> signup(
|
||||
@Valid @RequestBody SignupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.signup(request);
|
||||
httpRequest.getSession(true);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> login(
|
||||
@Valid @RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.login(request);
|
||||
HttpSession session = httpRequest.getSession(true);
|
||||
session.setAttribute("user_email", user.email());
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
||||
HttpSession session = httpRequest.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> me(Principal principal) {
|
||||
UserResponse user = authService.getCurrentUser(principal.getName());
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@PatchMapping("/me")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> updateProfile(
|
||||
Principal principal,
|
||||
@Valid @RequestBody UpdateProfileRequest request) {
|
||||
UserResponse user = authService.updateProfile(principal.getName(), request);
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
}
|
||||
10
backend/src/main/java/com/recipeapp/auth/AuthService.java
Normal file
10
backend/src/main/java/com/recipeapp/auth/AuthService.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.dto.*;
|
||||
|
||||
public interface AuthService {
|
||||
UserResponse signup(SignupRequest request);
|
||||
UserResponse login(LoginRequest request);
|
||||
UserResponse getCurrentUser(String email);
|
||||
UserResponse updateProfile(String email, UpdateProfileRequest request);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.dto.*;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdMemberRepository;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AuthServiceImpl implements AuthService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final HouseholdMemberRepository householdMemberRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AuthServiceImpl(UserAccountRepository userAccountRepository,
|
||||
HouseholdMemberRepository householdMemberRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.householdMemberRepository = householdMemberRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserResponse signup(SignupRequest request) {
|
||||
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
|
||||
throw new ConflictException("Email already registered");
|
||||
}
|
||||
var user = new UserAccount(
|
||||
request.email(),
|
||||
request.displayName(),
|
||||
passwordEncoder.encode(request.password())
|
||||
);
|
||||
user = userAccountRepository.save(user);
|
||||
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse login(LoginRequest request) {
|
||||
UserAccount user = userAccountRepository.findByEmailIgnoreCase(request.email())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invalid email or password"));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new ValidationException("Account is deactivated");
|
||||
}
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new ValidationException("Invalid email or password");
|
||||
}
|
||||
return toUserResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse getCurrentUser(String email) {
|
||||
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
return toUserResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserResponse updateProfile(String email, UpdateProfileRequest request) {
|
||||
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
if (request.displayName() != null) {
|
||||
user.setDisplayName(request.displayName());
|
||||
}
|
||||
if (request.newPassword() != null) {
|
||||
if (request.currentPassword() == null) {
|
||||
throw new ValidationException("Current password is required to set a new password");
|
||||
}
|
||||
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
|
||||
throw new ValidationException("Current password is incorrect");
|
||||
}
|
||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
}
|
||||
user = userAccountRepository.save(user);
|
||||
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(UserAccount user) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
|
||||
.map(member -> UserResponse.withHousehold(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(),
|
||||
member.getHousehold().getId(), member.getHousehold().getName(),
|
||||
member.getRole(), user.getSystemRole()))
|
||||
.orElse(UserResponse.withHousehold(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(),
|
||||
null, null, null, user.getSystemRole()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public CustomUserDetailsService(UserAccountRepository userAccountRepository) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||
UserAccount account = userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
|
||||
|
||||
if (!account.isActive()) {
|
||||
throw new UsernameNotFoundException("Account is deactivated: " + email);
|
||||
}
|
||||
|
||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + account.getSystemRole().toUpperCase()));
|
||||
|
||||
return new User(account.getEmail(), account.getPasswordHash(), authorities);
|
||||
}
|
||||
}
|
||||
43
backend/src/main/java/com/recipeapp/auth/SecurityConfig.java
Normal file
43
backend/src/main/java/com/recipeapp/auth/SecurityConfig.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.sessionManagement(session -> session
|
||||
.maximumSessions(1));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserAccountRepository extends JpaRepository<UserAccount, UUID> {
|
||||
Optional<UserAccount> findByEmailIgnoreCase(String email);
|
||||
boolean existsByEmailIgnoreCase(String email);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record SignupRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 8) String password,
|
||||
@NotBlank @Size(max = 100) String displayName
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
@Size(max = 100) String displayName,
|
||||
String currentPassword,
|
||||
@Size(min = 8) String newPassword
|
||||
) {}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record UserResponse(
|
||||
UUID id,
|
||||
String email,
|
||||
String displayName,
|
||||
UUID householdId,
|
||||
String householdName,
|
||||
String householdRole,
|
||||
String systemRole
|
||||
) {
|
||||
public static UserResponse basic(UUID id, String email, String displayName) {
|
||||
return new UserResponse(id, email, displayName, null, null, null, null);
|
||||
}
|
||||
|
||||
public static UserResponse withHousehold(UUID id, String email, String displayName,
|
||||
UUID householdId, String householdName,
|
||||
String householdRole, String systemRole) {
|
||||
return new UserResponse(id, email, displayName, householdId, householdName,
|
||||
householdRole, systemRole);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.recipeapp.auth.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_account")
|
||||
public class UserAccount {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true, columnDefinition = "citext")
|
||||
private String email;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 100)
|
||||
private String displayName;
|
||||
|
||||
@Column(name = "password_hash", nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(name = "system_role", nullable = false, length = 10)
|
||||
private String systemRole = "user";
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean isActive = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
protected UserAccount() {}
|
||||
|
||||
public UserAccount(String email, String displayName, String passwordHash) {
|
||||
this.email = email;
|
||||
this.displayName = displayName;
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getPasswordHash() { return passwordHash; }
|
||||
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
|
||||
public String getSystemRole() { return systemRole; }
|
||||
public void setSystemRole(String systemRole) { this.systemRole = systemRole; }
|
||||
public boolean isActive() { return isActive; }
|
||||
public void setActive(boolean active) { isActive = active; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.household;
|
||||
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.recipeapp.household.entity;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "household")
|
||||
public class Household {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by", nullable = false)
|
||||
private UserAccount createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected Household() {}
|
||||
|
||||
public Household(String name, UserAccount createdBy) {
|
||||
this.name = name;
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public UserAccount getCreatedBy() { return createdBy; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.recipeapp.household.entity;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "household_member")
|
||||
public class HouseholdMember {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "household_id", nullable = false)
|
||||
private Household household;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false, unique = true)
|
||||
private UserAccount user;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String role;
|
||||
|
||||
@Column(name = "joined_at", nullable = false, updatable = false)
|
||||
private Instant joinedAt;
|
||||
|
||||
protected HouseholdMember() {}
|
||||
|
||||
public HouseholdMember(Household household, UserAccount user, String role) {
|
||||
this.household = household;
|
||||
this.user = user;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
joinedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public UserAccount getUser() { return user; }
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
public Instant getJoinedAt() { return joinedAt; }
|
||||
}
|
||||
Reference in New Issue
Block a user