feat: 관리자 계정 식별자와 카카오 영구 교체 지원
관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
This commit is contained in:
@@ -3,10 +3,16 @@ export interface GatewayUserFlushEvent {
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
iconRevision?: string;
|
||||
displayName?: string;
|
||||
identityRevision?: string;
|
||||
}
|
||||
|
||||
export interface GatewayFlushPublisher {
|
||||
publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void>;
|
||||
publishUserFlush(
|
||||
userId: string,
|
||||
reason?: string,
|
||||
metadata?: { iconRevision?: string; displayName?: string; identityRevision?: string }
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
@@ -18,12 +24,18 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
async publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void> {
|
||||
async publishUserFlush(
|
||||
userId: string,
|
||||
reason?: string,
|
||||
metadata?: { iconRevision?: string; displayName?: string; identityRevision?: string }
|
||||
): Promise<void> {
|
||||
const payload: GatewayUserFlushEvent = {
|
||||
userId,
|
||||
flushedAt: new Date().toISOString(),
|
||||
reason,
|
||||
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
|
||||
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
|
||||
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
|
||||
};
|
||||
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
sanctions: user.sanctions,
|
||||
createdAt: user.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
authRevision: user.authRevision ?? 0,
|
||||
legacyMemberNo: user.legacyMemberNo,
|
||||
};
|
||||
this.sessions.set(sessionToken, {
|
||||
@@ -100,6 +101,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
sanctions: session.sanctions,
|
||||
createdAt: session.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
authRevision: session.authRevision ?? 0,
|
||||
legacyMemberNo: session.legacyMemberNo,
|
||||
};
|
||||
const key = buildGameKey(profile, gameToken);
|
||||
|
||||
@@ -28,6 +28,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const usersByName = new Map<string, UserRecord>();
|
||||
const usersByOauthId = new Map<string, UserRecord>();
|
||||
const usersByEmail = new Map<string, UserRecord>();
|
||||
const retiredKakaoIds = new Set<string>();
|
||||
const iconsById = new Map<string, UserIconRecord>();
|
||||
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
|
||||
|
||||
@@ -101,7 +102,8 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
}
|
||||
if (
|
||||
input.oauth &&
|
||||
(usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
|
||||
(retiredKakaoIds.has(input.oauth.id) ||
|
||||
usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
|
||||
usersByEmail.has(input.oauth.email.toLowerCase()))
|
||||
) {
|
||||
throw new Error('Kakao account already linked.');
|
||||
@@ -124,6 +126,8 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
oauthId: input.oauth?.id,
|
||||
email: input.oauth?.email,
|
||||
oauthInfo: input.oauth?.info,
|
||||
identityRevision: now.toISOString(),
|
||||
authRevision: 0,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
thirdPartyUse: input.thirdPartyUse ?? false,
|
||||
@@ -213,7 +217,11 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
|
||||
const emailOwner = usersByEmail.get(normalizedEmail);
|
||||
if ((oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId)) {
|
||||
if (
|
||||
retiredKakaoIds.has(input.oauthId) ||
|
||||
(oauthOwner && oauthOwner.id !== userId) ||
|
||||
(emailOwner && emailOwner.id !== userId)
|
||||
) {
|
||||
throw new Error('Kakao account already linked.');
|
||||
}
|
||||
for (const user of usersByName.values()) {
|
||||
@@ -242,7 +250,11 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
|
||||
const emailOwner = usersByEmail.get(normalizedEmail);
|
||||
if ((oauthOwner && oauthOwner.id !== userId) || emailOwner?.id !== userId) {
|
||||
if (
|
||||
retiredKakaoIds.has(input.oauthId) ||
|
||||
(oauthOwner && oauthOwner.id !== userId) ||
|
||||
emailOwner?.id !== userId
|
||||
) {
|
||||
throw new Error('Kakao account recovery ownership changed.');
|
||||
}
|
||||
for (const user of usersByName.values()) {
|
||||
@@ -262,6 +274,86 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
}
|
||||
throw new Error('Kakao account recovery ownership changed.');
|
||||
},
|
||||
async isKakaoIdentityRetired(oauthId: string): Promise<boolean> {
|
||||
return retiredKakaoIds.has(oauthId);
|
||||
},
|
||||
async setKakaoReplacementApproval(userId, input): Promise<UserRecord> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) continue;
|
||||
user.kakaoReplacementApprovedUntil = input.until?.toISOString();
|
||||
user.kakaoReplacementApprovedByUserId = input.until ? input.approvedByUserId : undefined;
|
||||
user.kakaoReplacementReason = input.until ? input.reason : undefined;
|
||||
return user;
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async replaceKakaoWithApprovedIdentity(userId, input): Promise<UserRecord> {
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
|
||||
const emailOwner = usersByEmail.get(normalizedEmail);
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) continue;
|
||||
const approvedUntil = user.kakaoReplacementApprovedUntil
|
||||
? new Date(user.kakaoReplacementApprovedUntil)
|
||||
: null;
|
||||
if (
|
||||
user.oauthType !== 'KAKAO' ||
|
||||
!user.oauthId ||
|
||||
user.oauthId === input.oauthId ||
|
||||
!approvedUntil ||
|
||||
approvedUntil < input.verifiedAt ||
|
||||
!user.kakaoReplacementApprovedByUserId ||
|
||||
!user.kakaoReplacementReason ||
|
||||
retiredKakaoIds.has(input.oauthId) ||
|
||||
(oauthOwner && oauthOwner.id !== userId) ||
|
||||
(emailOwner && emailOwner.id !== userId)
|
||||
) {
|
||||
throw new Error('Kakao account replacement is not approved.');
|
||||
}
|
||||
retiredKakaoIds.add(user.oauthId);
|
||||
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
|
||||
if (user.email) usersByEmail.delete(user.email.toLowerCase());
|
||||
user.oauthId = input.oauthId;
|
||||
user.email = normalizedEmail;
|
||||
user.oauthInfo = input.oauthInfo;
|
||||
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
|
||||
user.kakaoTalkVerifiedUntil = undefined;
|
||||
user.kakaoReplacementApprovedUntil = undefined;
|
||||
user.kakaoReplacementApprovedByUserId = undefined;
|
||||
user.kakaoReplacementReason = undefined;
|
||||
user.sessionRevokedBefore = input.verifiedAt.toISOString();
|
||||
user.authRevision = (user.authRevision ?? 0) + 1;
|
||||
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
|
||||
usersByEmail.set(normalizedEmail, user);
|
||||
return user;
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async updateIdentity(userId, input): Promise<UserRecord> {
|
||||
const usernameOwner = usersByName.get(input.username);
|
||||
const displayNameOwner = [...usersByName.values()].find(
|
||||
(candidate) => candidate.displayName === input.displayName
|
||||
);
|
||||
if (
|
||||
(usernameOwner && usernameOwner.id !== userId) ||
|
||||
(displayNameOwner && displayNameOwner.id !== userId)
|
||||
) {
|
||||
throw new Error('Account identity already exists.');
|
||||
}
|
||||
for (const [username, user] of usersByName.entries()) {
|
||||
if (user.id !== userId) continue;
|
||||
const nextRevision = new Date(
|
||||
Math.max(input.changedAt.getTime(), new Date(user.identityRevision ?? user.createdAt).getTime() + 1)
|
||||
).toISOString();
|
||||
usersByName.delete(username);
|
||||
user.username = input.username;
|
||||
user.displayName = input.displayName;
|
||||
user.identityRevision = nextRevision;
|
||||
usersByName.set(input.username, user);
|
||||
return user;
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
|
||||
@@ -66,6 +66,12 @@ const mapUser = (row: {
|
||||
oauthId: string | null;
|
||||
email: string | null;
|
||||
oauthInfo: GatewayPrisma.JsonValue;
|
||||
identityRevision: Date;
|
||||
authRevision: number;
|
||||
sessionRevokedBefore: Date | null;
|
||||
kakaoReplacementApprovedUntil: Date | null;
|
||||
kakaoReplacementApprovedByUserId: string | null;
|
||||
kakaoReplacementReason: string | null;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt: Date | null;
|
||||
@@ -92,6 +98,12 @@ const mapUser = (row: {
|
||||
oauthId: row.oauthId ?? undefined,
|
||||
email: row.email ?? undefined,
|
||||
oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}),
|
||||
identityRevision: row.identityRevision.toISOString(),
|
||||
authRevision: row.authRevision,
|
||||
sessionRevokedBefore: row.sessionRevokedBefore?.toISOString(),
|
||||
kakaoReplacementApprovedUntil: row.kakaoReplacementApprovedUntil?.toISOString(),
|
||||
kakaoReplacementApprovedByUserId: row.kakaoReplacementApprovedByUserId ?? undefined,
|
||||
kakaoReplacementReason: row.kakaoReplacementReason ?? undefined,
|
||||
picture: row.picture,
|
||||
imageServer: row.imageServer,
|
||||
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
|
||||
@@ -246,26 +258,39 @@ export const createPostgresUserRepository = (
|
||||
const password = await hasher.hash(input.password);
|
||||
const oauthType = input.oauth?.type ?? 'NONE';
|
||||
const now = new Date();
|
||||
const row = await prisma.appUser.create({
|
||||
data: {
|
||||
loginId: input.username,
|
||||
displayName: input.displayName ?? input.username,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
passwordResetRequired: false,
|
||||
roles: ['user'] satisfies GatewayPrisma.JsonArray,
|
||||
sanctions: {} satisfies GatewayPrisma.JsonObject,
|
||||
oauthType,
|
||||
oauthId: input.oauth?.id,
|
||||
email: input.oauth?.email?.toLowerCase(),
|
||||
oauthInfo: (input.oauth?.info ?? {}) as GatewayPrisma.JsonObject,
|
||||
termsAcceptedAt: input.termsAcceptedAt,
|
||||
privacyAcceptedAt: input.privacyAcceptedAt,
|
||||
thirdPartyUse: input.thirdPartyUse ?? false,
|
||||
kakaoVerifiedAt: input.oauth ? now : undefined,
|
||||
kakaoGraceStartedAt: now,
|
||||
},
|
||||
});
|
||||
const data: GatewayPrisma.AppUserCreateInput = {
|
||||
loginId: input.username,
|
||||
displayName: input.displayName ?? input.username,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
passwordResetRequired: false,
|
||||
roles: ['user'] satisfies GatewayPrisma.JsonArray,
|
||||
sanctions: {} satisfies GatewayPrisma.JsonObject,
|
||||
oauthType,
|
||||
oauthId: input.oauth?.id,
|
||||
email: input.oauth?.email?.toLowerCase(),
|
||||
oauthInfo: (input.oauth?.info ?? {}) as GatewayPrisma.JsonObject,
|
||||
termsAcceptedAt: input.termsAcceptedAt,
|
||||
privacyAcceptedAt: input.privacyAcceptedAt,
|
||||
thirdPartyUse: input.thirdPartyUse ?? false,
|
||||
kakaoVerifiedAt: input.oauth ? now : undefined,
|
||||
kakaoGraceStartedAt: now,
|
||||
};
|
||||
const row = input.oauth
|
||||
? await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const retired = await tx.retiredKakaoIdentity.findUnique({
|
||||
where: { oauthId: input.oauth!.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (retired) {
|
||||
throw new Error('Kakao identity is permanently retired.');
|
||||
}
|
||||
return tx.appUser.create({ data });
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
)
|
||||
: await prisma.appUser.create({ data });
|
||||
return mapUser(row);
|
||||
},
|
||||
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
|
||||
@@ -323,40 +348,188 @@ export const createPostgresUserRepository = (
|
||||
return mapUser(row);
|
||||
},
|
||||
async linkKakao(userId, input): Promise<UserRecord> {
|
||||
const row = await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: input.oauthId,
|
||||
email: input.email.toLowerCase(),
|
||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||
kakaoVerifiedAt: input.verifiedAt,
|
||||
kakaoTalkVerifiedUntil: null,
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
const retired = await tx.retiredKakaoIdentity.findUnique({
|
||||
where: { oauthId: input.oauthId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (retired) {
|
||||
throw new Error('Kakao identity is permanently retired.');
|
||||
}
|
||||
const row = await tx.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: input.oauthId,
|
||||
email: input.email.toLowerCase(),
|
||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||
kakaoVerifiedAt: input.verifiedAt,
|
||||
kakaoTalkVerifiedUntil: null,
|
||||
},
|
||||
});
|
||||
return mapUser(row);
|
||||
},
|
||||
});
|
||||
return mapUser(row);
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
},
|
||||
async relinkKakaoByEmail(userId, input): Promise<UserRecord> {
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const updated = await prisma.appUser.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
email: normalizedEmail,
|
||||
},
|
||||
data: {
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: input.oauthId,
|
||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||
kakaoVerifiedAt: input.verifiedAt,
|
||||
kakaoTalkVerifiedUntil: null,
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
const retired = await tx.retiredKakaoIdentity.findUnique({
|
||||
where: { oauthId: input.oauthId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (retired) {
|
||||
throw new Error('Kakao identity is permanently retired.');
|
||||
}
|
||||
const updated = await tx.appUser.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
email: normalizedEmail,
|
||||
},
|
||||
data: {
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: input.oauthId,
|
||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||
kakaoVerifiedAt: input.verifiedAt,
|
||||
kakaoTalkVerifiedUntil: null,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error('Kakao account recovery ownership changed.');
|
||||
}
|
||||
const row = await tx.appUser.findUniqueOrThrow({ where: { id: userId } });
|
||||
return mapUser(row);
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
},
|
||||
async isKakaoIdentityRetired(oauthId: string): Promise<boolean> {
|
||||
return (await prisma.retiredKakaoIdentity.count({ where: { oauthId } })) > 0;
|
||||
},
|
||||
async setKakaoReplacementApproval(userId, input): Promise<UserRecord> {
|
||||
const row = await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
data: input.until
|
||||
? {
|
||||
kakaoReplacementApprovedUntil: input.until,
|
||||
kakaoReplacementApprovedByUserId: input.approvedByUserId,
|
||||
kakaoReplacementReason: input.reason,
|
||||
}
|
||||
: {
|
||||
kakaoReplacementApprovedUntil: null,
|
||||
kakaoReplacementApprovedByUserId: null,
|
||||
kakaoReplacementReason: null,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error('Kakao account recovery ownership changed.');
|
||||
}
|
||||
const row = await prisma.appUser.findUniqueOrThrow({ where: { id: userId } });
|
||||
return mapUser(row);
|
||||
},
|
||||
async replaceKakaoWithApprovedIdentity(userId, input): Promise<UserRecord> {
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.appUser.findUnique({ where: { id: userId } });
|
||||
const now = input.verifiedAt;
|
||||
if (
|
||||
!user ||
|
||||
user.oauthType !== 'KAKAO' ||
|
||||
!user.oauthId ||
|
||||
user.oauthId === input.oauthId ||
|
||||
!user.kakaoReplacementApprovedUntil ||
|
||||
user.kakaoReplacementApprovedUntil < now ||
|
||||
!user.kakaoReplacementApprovedByUserId ||
|
||||
!user.kakaoReplacementReason
|
||||
) {
|
||||
throw new Error('Kakao account replacement is not approved.');
|
||||
}
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const [oauthOwner, emailOwner, retired] = await Promise.all([
|
||||
tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
|
||||
tx.appUser.findUnique({ where: { email: normalizedEmail }, select: { id: true } }),
|
||||
tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
|
||||
]);
|
||||
if (
|
||||
retired ||
|
||||
(oauthOwner && oauthOwner.id !== userId) ||
|
||||
(emailOwner && emailOwner.id !== userId)
|
||||
) {
|
||||
throw new Error('Kakao account replacement ownership changed.');
|
||||
}
|
||||
await tx.retiredKakaoIdentity.create({
|
||||
data: {
|
||||
oauthId: user.oauthId,
|
||||
formerUserId: user.id,
|
||||
approvedByUserId: user.kakaoReplacementApprovedByUserId,
|
||||
reason: user.kakaoReplacementReason,
|
||||
retiredAt: now,
|
||||
},
|
||||
});
|
||||
const updated = await tx.appUser.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: user.oauthId,
|
||||
kakaoReplacementApprovedUntil: { gte: now },
|
||||
kakaoReplacementApprovedByUserId: user.kakaoReplacementApprovedByUserId,
|
||||
kakaoReplacementReason: user.kakaoReplacementReason,
|
||||
},
|
||||
data: {
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: input.oauthId,
|
||||
email: normalizedEmail,
|
||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||
kakaoVerifiedAt: now,
|
||||
kakaoTalkVerifiedUntil: null,
|
||||
kakaoReplacementApprovedUntil: null,
|
||||
kakaoReplacementApprovedByUserId: null,
|
||||
kakaoReplacementReason: null,
|
||||
sessionRevokedBefore: now,
|
||||
authRevision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error('Kakao account replacement approval changed.');
|
||||
}
|
||||
const row = await tx.appUser.findUniqueOrThrow({ where: { id: userId } });
|
||||
return mapUser(row);
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
},
|
||||
async updateIdentity(userId, input): Promise<UserRecord> {
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
const current = await tx.appUser.findUnique({ where: { id: userId } });
|
||||
if (!current) {
|
||||
throw new Error('User not found.');
|
||||
}
|
||||
const [usernameOwner, displayNameOwner] = await Promise.all([
|
||||
tx.appUser.findUnique({ where: { loginId: input.username }, select: { id: true } }),
|
||||
tx.appUser.findUnique({ where: { displayName: input.displayName }, select: { id: true } }),
|
||||
]);
|
||||
if (
|
||||
(usernameOwner && usernameOwner.id !== userId) ||
|
||||
(displayNameOwner && displayNameOwner.id !== userId)
|
||||
) {
|
||||
throw new Error('Account identity already exists.');
|
||||
}
|
||||
const nextRevision = new Date(
|
||||
Math.max(input.changedAt.getTime(), current.identityRevision.getTime() + 1)
|
||||
);
|
||||
const row = await tx.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
loginId: input.username,
|
||||
displayName: input.displayName,
|
||||
identityRevision: nextRevision,
|
||||
},
|
||||
});
|
||||
return mapUser(row);
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
},
|
||||
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -56,6 +56,7 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
sanctions: user.sanctions,
|
||||
createdAt: user.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
authRevision: user.authRevision ?? 0,
|
||||
legacyMemberNo: user.legacyMemberNo,
|
||||
};
|
||||
await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), {
|
||||
@@ -109,6 +110,7 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
sanctions: session.sanctions,
|
||||
createdAt: session.createdAt,
|
||||
issuedAt: new Date().toISOString(),
|
||||
authRevision: session.authRevision ?? 0,
|
||||
legacyMemberNo: session.legacyMemberNo,
|
||||
};
|
||||
const gameKey = this.keys.gameSessionKey(profile, gameToken);
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface GatewaySessionInfo {
|
||||
sanctions: UserSanctions;
|
||||
createdAt: string;
|
||||
issuedAt: string;
|
||||
authRevision?: number;
|
||||
legacyMemberNo?: number;
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface GameSessionInfo {
|
||||
sanctions: UserSanctions;
|
||||
createdAt: string;
|
||||
issuedAt: string;
|
||||
authRevision?: number;
|
||||
legacyMemberNo?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { GatewaySessionInfo } from './sessionService.js';
|
||||
import type { UserRecord } from './userRepository.js';
|
||||
|
||||
export const isGatewaySessionCurrent = (session: GatewaySessionInfo, user: UserRecord): boolean => {
|
||||
if ((session.authRevision ?? 0) !== (user.authRevision ?? 0)) return false;
|
||||
if (!user.sessionRevokedBefore) return true;
|
||||
const issuedAt = new Date(session.issuedAt).getTime();
|
||||
const revokedBefore = new Date(user.sessionRevokedBefore).getTime();
|
||||
return Number.isFinite(issuedAt) && Number.isFinite(revokedBefore) && issuedAt >= revokedBefore;
|
||||
};
|
||||
@@ -8,6 +8,12 @@ export interface UserRecord {
|
||||
oauthId?: string;
|
||||
email?: string;
|
||||
oauthInfo?: UserOAuthInfo;
|
||||
identityRevision?: string;
|
||||
authRevision?: number;
|
||||
sessionRevokedBefore?: string;
|
||||
kakaoReplacementApprovedUntil?: string;
|
||||
kakaoReplacementApprovedByUserId?: string;
|
||||
kakaoReplacementReason?: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt?: string;
|
||||
@@ -181,6 +187,24 @@ export interface UserRepository {
|
||||
verifiedAt: Date;
|
||||
}
|
||||
): Promise<UserRecord>;
|
||||
isKakaoIdentityRetired(oauthId: string): Promise<boolean>;
|
||||
setKakaoReplacementApproval(
|
||||
userId: string,
|
||||
input: { until: Date | null; approvedByUserId: string; reason: string }
|
||||
): Promise<UserRecord>;
|
||||
replaceKakaoWithApprovedIdentity(
|
||||
userId: string,
|
||||
input: {
|
||||
oauthId: string;
|
||||
email: string;
|
||||
oauthInfo: UserOAuthInfo;
|
||||
verifiedAt: Date;
|
||||
}
|
||||
): Promise<UserRecord>;
|
||||
updateIdentity(
|
||||
userId: string,
|
||||
input: { username: string; displayName: string; changedAt: Date }
|
||||
): Promise<UserRecord>;
|
||||
updateRoles(userId: string, roles: string[]): Promise<void>;
|
||||
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
|
||||
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user