diff --git a/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql new file mode 100644 index 00000000..3b6ac7a4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql @@ -0,0 +1,9 @@ +CREATE TABLE namespace_invitation ( + namespace_id BIGINT PRIMARY KEY REFERENCES namespace(id) ON DELETE CASCADE, + token_hash CHAR(64) NOT NULL UNIQUE, + issued_by VARCHAR(128) NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_by VARCHAR(128), + consumed_at TIMESTAMPTZ +); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java new file mode 100644 index 00000000..eb97b7c1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "namespace_invitation") +public class NamespaceInvitation { + @Id + @Column(name = "namespace_id") + private Long namespaceId; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "issued_by", nullable = false, length = 128) + private String issuedBy; + + @Column(name = "issued_at", nullable = false) + private Instant issuedAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_by", length = 128) + private String consumedBy; + + @Column(name = "consumed_at") + private Instant consumedAt; + + protected NamespaceInvitation() {} + + public NamespaceInvitation(Long namespaceId, String tokenHash, String issuedBy, Instant issuedAt, Instant expiresAt) { + this.namespaceId = namespaceId; + this.tokenHash = tokenHash; + this.issuedBy = issuedBy; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public Long getNamespaceId() { return namespaceId; } + public String getTokenHash() { return tokenHash; } + public String getIssuedBy() { return issuedBy; } + public Instant getIssuedAt() { return issuedAt; } + public Instant getExpiresAt() { return expiresAt; } + public String getConsumedBy() { return consumedBy; } + public Instant getConsumedAt() { return consumedAt; } + public void replace(String hash, String issuer, Instant now) { + this.tokenHash = hash; + this.issuedBy = issuer; + this.issuedAt = now; + this.expiresAt = now.plusSeconds(86400); + this.consumedBy = null; + this.consumedAt = null; + } + public void consume(String userId, Instant now) { + this.consumedBy = userId; + this.consumedAt = now; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java new file mode 100644 index 00000000..cf54afb3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Optional; + +public interface NamespaceInvitationRepository { + Optional findByNamespaceId(Long namespaceId); + Optional findByTokenHash(String tokenHash); + NamespaceInvitation save(NamespaceInvitation invitation); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java new file mode 100644 index 00000000..877b94eb --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -0,0 +1,163 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; + +@Service +public class NamespaceInvitationService { + private static final SecureRandom RANDOM = new SecureRandom(); + private final NamespaceRepository namespaces; + private final NamespaceInvitationRepository invitations; + private final NamespaceMemberRepository members; + private final UserAccountRepository users; + private final AuditLogService audit; + private final Clock clock; + + public NamespaceInvitationService(NamespaceRepository namespaces, NamespaceInvitationRepository invitations, + NamespaceMemberRepository members, UserAccountRepository users, + AuditLogService audit, Clock clock) { + this.namespaces = namespaces; + this.invitations = invitations; + this.members = members; + this.users = users; + this.audit = audit; + this.clock = clock; + } + + public record Created(String token, Instant expiresAt) {} + public record Preview(String namespaceSlug, String namespaceName, boolean alreadyMember) {} + + @Transactional + public Created create(String slug, String actorId) { + Namespace namespace = lock(slug); + requireManager(namespace, actorId); + requireActiveTeam(namespace); + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + Instant now = clock.instant(); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null); + boolean replaced = invitation != null; + if (invitation == null) { + invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400)); + } else { + invitation.replace(hash(token), actorId, now); + } + invitations.save(invitation); + record(actorId, replaced ? "NAMESPACE_INVITE_REPLACED" : "NAMESPACE_INVITE_CREATED", namespace.getId()); + return new Created(token, invitation.getExpiresAt()); + } + + @Transactional + public void revoke(String slug, String actorId) { + Namespace namespace = lock(slug); + requireManager(namespace, actorId); + requireActiveTeam(namespace); + if (invitations.findByNamespaceId(namespace.getId()).isEmpty()) { + throw invalid(); + } + invitations.deleteByNamespaceId(namespace.getId()); + record(actorId, "NAMESPACE_INVITE_REVOKED", namespace.getId()); + } + + @Transactional(readOnly = true) + public boolean hasActiveInvitation(String slug, String actorId) { + Namespace namespace = namespaces.findBySlug(slug) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.invite.invalid")); + requireManager(namespace, actorId); + return invitations.findByNamespaceId(namespace.getId()) + .filter(invite -> invite.getConsumedAt() == null && invite.getExpiresAt().isAfter(clock.instant())) + .isPresent(); + } + + @Transactional(readOnly = true) + public Preview preview(String token, String userId) { + NamespaceInvitation invitation = current(token); + Namespace namespace = namespaces.findById(invitation.getNamespaceId()).orElseThrow(this::invalid); + boolean member = members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent(); + if (!member) { + requireActiveUser(userId); + requireValid(namespace, invitation); + } + return new Preview(namespace.getSlug(), namespace.getDisplayName(), member); + } + + @Transactional + public Preview accept(String token, String userId) { + NamespaceInvitation located = current(token); + Namespace namespace = namespaces.findLockedById(located.getNamespaceId()).orElseThrow(this::invalid); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid); + if (!MessageDigest.isEqual(invitation.getTokenHash().getBytes(StandardCharsets.US_ASCII), + hash(token).getBytes(StandardCharsets.US_ASCII))) { + throw invalid(); + } + if (members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent()) { + return new Preview(namespace.getSlug(), namespace.getDisplayName(), true); + } + requireActiveUser(userId); + requireValid(namespace, invitation); + members.save(new NamespaceMember(namespace.getId(), userId, NamespaceRole.MEMBER)); + invitation.consume(userId, clock.instant()); + invitations.save(invitation); + record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId()); + return new Preview(namespace.getSlug(), namespace.getDisplayName(), false); + } + + private Namespace lock(String slug) { + Namespace found = namespaces.findBySlug(slug).orElseThrow(this::invalid); + return namespaces.findLockedById(found.getId()).orElseThrow(this::invalid); + } + + private NamespaceInvitation current(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid(); + return invitations.findByTokenHash(hash(token)).orElseThrow(this::invalid); + } + + private void requireManager(Namespace namespace, String userId) { + NamespaceRole role = members.findByNamespaceIdAndUserId(namespace.getId(), userId) + .map(NamespaceMember::getRole).orElse(null); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) throw invalid(); + requireActiveUser(userId); + } + + private void requireActiveUser(String userId) { + if (users.findById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); + } + + private void requireActiveTeam(Namespace namespace) { + if (namespace.getStatus() != NamespaceStatus.ACTIVE || namespace.getType() != NamespaceType.TEAM) throw invalid(); + } + + private void requireValid(Namespace namespace, NamespaceInvitation invitation) { + requireActiveTeam(namespace); + if (invitation.getConsumedAt() != null || !invitation.getExpiresAt().isAfter(clock.instant())) throw invalid(); + } + + private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invite.invalid"); } + + private String hash(String token) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(token.getBytes(StandardCharsets.US_ASCII))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private void record(String actorId, String action, Long namespaceId) { + audit.record(actorId, action, "NAMESPACE", namespaceId, null, null, null, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index cdedbfec..d5bbedbc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -11,6 +11,7 @@ import java.util.Optional; */ public interface NamespaceRepository { Optional findById(Long id); + Optional findLockedById(Long id); List findAll(); List findByIdIn(List ids); List findBySlugIn(List slugs); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java new file mode 100644 index 00000000..a2d78607 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class NamespaceInvitationServiceTest { + private final NamespaceRepository namespaces = mock(NamespaceRepository.class); + private final NamespaceInvitationRepository invitations = mock(NamespaceInvitationRepository.class); + private final NamespaceMemberRepository members = mock(NamespaceMemberRepository.class); + private final UserAccountRepository users = mock(UserAccountRepository.class); + private final AuditLogService audit = mock(AuditLogService.class); + private final Clock clock = Clock.fixed(Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC); + private final NamespaceInvitationService service = new NamespaceInvitationService(namespaces, invitations, members, users, audit, clock); + + @BeforeEach + void setup() { + Namespace namespace = new Namespace("team", "Team", "owner"); + // Entity ID is database-generated; mock the locked lookup with the same instance. + try { + var id = Namespace.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(namespace, 7L); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(namespaces.findLockedById(7L)).thenReturn(Optional.of(namespace)); + when(namespaces.findById(7L)).thenReturn(Optional.of(namespace)); + when(members.findByNamespaceIdAndUserId(7L, "owner")) + .thenReturn(Optional.of(new NamespaceMember(7L, "owner", NamespaceRole.OWNER))); + UserAccount owner = mock(UserAccount.class); + when(owner.getStatus()).thenReturn(com.iflytek.skillhub.domain.user.UserStatus.ACTIVE); + when(users.findById("owner")).thenReturn(Optional.of(owner)); + when(users.findById("guest")).thenReturn(Optional.of(owner)); + } + + @Test + void createsSingleUseTokenWithoutPersistingRawValue() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + assertThat(created.token()).matches("[A-Za-z0-9_-]{43}"); + assertThat(saved.getValue().getTokenHash()).matches("[a-f0-9]{64}").doesNotContain(created.token()); + assertThat(created.expiresAt()).isEqualTo(Instant.parse("2026-10-13T00:00:00Z")); + } + + @Test + void revokedTokenCannotBePreviewed() { + assertThatThrownBy(() -> service.preview("A".repeat(43), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations).findByTokenHash(any()); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java new file mode 100644 index 00000000..074fa035 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.namespace.NamespaceInvitation; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NamespaceInvitationJpaRepository extends JpaRepository, NamespaceInvitationRepository { + Optional findByTokenHash(String tokenHash); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index ed9a6724..0f909e58 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -6,6 +6,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import jakarta.persistence.LockModeType; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -17,6 +21,10 @@ import java.util.Optional; @Repository public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select n from Namespace n where n.id = :id") + Optional findLockedById(@Param("id") Long id); List findByIdIn(List ids); List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable);