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