feat: add user entity, registration, and RBAC model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.auth.dto.AuthResponse;
|
||||||
|
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public AuthService(UserRepository userRepository,
|
||||||
|
RoleRepository roleRepository,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtService jwtService,
|
||||||
|
AuditService auditService) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.roleRepository = roleRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthResponse register(RegisterRequest request, String sourceIp) {
|
||||||
|
if (userRepository.existsByEmail(request.email())) {
|
||||||
|
throw new IllegalArgumentException("Email already registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new UserEntity();
|
||||||
|
user.setEmail(request.email());
|
||||||
|
user.setName(request.name());
|
||||||
|
user.setPassword(passwordEncoder.encode(request.password()));
|
||||||
|
|
||||||
|
roleRepository.findByName("OWNER").ifPresent(role -> user.getRoles().add(role));
|
||||||
|
|
||||||
|
var saved = userRepository.save(user);
|
||||||
|
var token = jwtService.generateToken(saved);
|
||||||
|
|
||||||
|
auditService.log(
|
||||||
|
saved.getId(), saved.getEmail(), null,
|
||||||
|
AuditAction.AUTH_REGISTER, null,
|
||||||
|
null, sourceIp,
|
||||||
|
"SUCCESS", null
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AuthResponse(token, saved.getEmail(), saved.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java
Normal file
19
src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
public String generateToken(UserEntity user) {
|
||||||
|
return "stub-token";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractEmail(String token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenValid(String token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "permissions")
|
||||||
|
public class PermissionEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, unique = true, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java
Normal file
94
src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
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.JoinColumn;
|
||||||
|
import jakarta.persistence.JoinTable;
|
||||||
|
import jakarta.persistence.ManyToMany;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "roles")
|
||||||
|
public class RoleEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, unique = true, length = 50)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "built_in", nullable = false)
|
||||||
|
private boolean builtIn;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
|
@JoinTable(
|
||||||
|
name = "role_permissions",
|
||||||
|
joinColumns = @JoinColumn(name = "role_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "permission_id")
|
||||||
|
)
|
||||||
|
private Set<PermissionEntity> permissions = new HashSet<>();
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
if (createdAt == null) {
|
||||||
|
createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBuiltIn() {
|
||||||
|
return builtIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuiltIn(boolean builtIn) {
|
||||||
|
this.builtIn = builtIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<PermissionEntity> getPermissions() {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPermissions(Set<PermissionEntity> permissions) {
|
||||||
|
this.permissions = permissions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RoleRepository extends JpaRepository<RoleEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<RoleEntity> findByName(String name);
|
||||||
|
}
|
||||||
122
src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java
Normal file
122
src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
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.JoinColumn;
|
||||||
|
import jakarta.persistence.JoinTable;
|
||||||
|
import jakarta.persistence.ManyToMany;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
public class UserEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "email", nullable = false, unique = true)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(name = "password", nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private String status = "ACTIVE";
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
|
@JoinTable(
|
||||||
|
name = "user_roles",
|
||||||
|
joinColumns = @JoinColumn(name = "user_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "role_id")
|
||||||
|
)
|
||||||
|
private Set<RoleEntity> roles = new HashSet<>();
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (createdAt == null) {
|
||||||
|
createdAt = now;
|
||||||
|
}
|
||||||
|
if (updatedAt == null) {
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected 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 getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<RoleEntity> getRoles() {
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRoles(Set<RoleEntity> roles) {
|
||||||
|
this.roles = roles;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface UserRepository extends JpaRepository<UserEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<UserEntity> findByEmail(String email);
|
||||||
|
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth.dto;
|
||||||
|
|
||||||
|
public record AuthResponse(
|
||||||
|
String token,
|
||||||
|
String email,
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record RegisterRequest(
|
||||||
|
@NotBlank @Email String email,
|
||||||
|
@NotBlank String name,
|
||||||
|
@NotBlank @Size(min = 8, max = 128) String password
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package net.siegeln.cameleer.saas.auth;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuthServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
@Mock
|
||||||
|
private RoleRepository roleRepository;
|
||||||
|
@Mock
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
@Mock
|
||||||
|
private JwtService jwtService;
|
||||||
|
@Mock
|
||||||
|
private AuditService auditService;
|
||||||
|
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
authService = new AuthService(userRepository, roleRepository,
|
||||||
|
passwordEncoder, jwtService, auditService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_createsUserAndReturnsToken() {
|
||||||
|
var request = new RegisterRequest("user@example.com", "Test User", "password123");
|
||||||
|
var ownerRole = new RoleEntity();
|
||||||
|
ownerRole.setName("OWNER");
|
||||||
|
|
||||||
|
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode("password123")).thenReturn("encoded-password");
|
||||||
|
when(roleRepository.findByName("OWNER")).thenReturn(Optional.of(ownerRole));
|
||||||
|
when(userRepository.save(any(UserEntity.class))).thenAnswer(invocation -> {
|
||||||
|
UserEntity user = invocation.getArgument(0);
|
||||||
|
// simulate ID assignment by persistence
|
||||||
|
try {
|
||||||
|
var idField = UserEntity.class.getDeclaredField("id");
|
||||||
|
idField.setAccessible(true);
|
||||||
|
idField.set(user, java.util.UUID.randomUUID());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
when(jwtService.generateToken(any(UserEntity.class))).thenReturn("test-jwt-token");
|
||||||
|
|
||||||
|
var response = authService.register(request, "127.0.0.1");
|
||||||
|
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals("test-jwt-token", response.token());
|
||||||
|
assertEquals("user@example.com", response.email());
|
||||||
|
assertEquals("Test User", response.name());
|
||||||
|
|
||||||
|
// Verify audit was logged
|
||||||
|
verify(auditService).log(
|
||||||
|
any(), eq("user@example.com"), eq(null),
|
||||||
|
eq(AuditAction.AUTH_REGISTER), eq(null),
|
||||||
|
eq(null), eq("127.0.0.1"),
|
||||||
|
eq("SUCCESS"), eq(null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_rejectsDuplicateEmail() {
|
||||||
|
var request = new RegisterRequest("existing@example.com", "Test User", "password123");
|
||||||
|
when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);
|
||||||
|
|
||||||
|
var exception = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> authService.register(request, "127.0.0.1"));
|
||||||
|
|
||||||
|
assertEquals("Email already registered", exception.getMessage());
|
||||||
|
verify(userRepository, never()).save(any());
|
||||||
|
verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user