mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
feat(namespace): add single-use invitation persistence
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
a463efb535
commit
b1d5391102
8 changed files with 337 additions and 0 deletions
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue