feat(namespace): add single-use invitation persistence

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 11:34:25 +08:00
parent a463efb535
commit b1d5391102
8 changed files with 337 additions and 0 deletions

View file

@ -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
);

View file

@ -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;
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.domain.namespace;
import java.util.Optional;
public interface NamespaceInvitationRepository {
Optional<NamespaceInvitation> findByNamespaceId(Long namespaceId);
Optional<NamespaceInvitation> findByTokenHash(String tokenHash);
NamespaceInvitation save(NamespaceInvitation invitation);
void deleteByNamespaceId(Long namespaceId);
}

View file

@ -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);
}
}

View file

@ -11,6 +11,7 @@ import java.util.Optional;
*/
public interface NamespaceRepository {
Optional<Namespace> findById(Long id);
Optional<Namespace> findLockedById(Long id);
List<Namespace> findAll();
List<Namespace> findByIdIn(List<Long> ids);
List<Namespace> findBySlugIn(List<String> slugs);

View file

@ -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<NamespaceInvitation> 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());
}
}

View file

@ -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<NamespaceInvitation, Long>, NamespaceInvitationRepository {
Optional<NamespaceInvitation> findByTokenHash(String tokenHash);
void deleteByNamespaceId(Long namespaceId);
}

View file

@ -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<Namespace, Long>, NamespaceRepository {
@Override
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select n from Namespace n where n.id = :id")
Optional<Namespace> findLockedById(@Param("id") Long id);
List<Namespace> findByIdIn(List<Long> ids);
List<Namespace> findBySlugIn(List<String> slugs);
Page<Namespace> findByIdIn(List<Long> ids, Pageable pageable);