feat: 레거시 재이관과 계정 복구 기반을 추가
중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
@@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
kakaoGraceStartedAt: now.toISOString(),
|
||||
passwordSalt: password.salt,
|
||||
passwordHash: password.hash,
|
||||
passwordResetRequired: false,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
usersByName.set(input.username, user);
|
||||
@@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const upgraded = await hasher.hash(password);
|
||||
user.passwordSalt = upgraded.salt;
|
||||
user.passwordHash = upgraded.hash;
|
||||
user.passwordResetRequired = false;
|
||||
}
|
||||
return verified.ok;
|
||||
},
|
||||
@@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const next = await hasher.hash(password);
|
||||
user.passwordSalt = next.salt;
|
||||
user.passwordHash = next.hash;
|
||||
user.passwordResetRequired = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = (
|
||||
grants: readonly SpecialAccountAccessGrantRecord[],
|
||||
now: Date = new Date()
|
||||
): boolean =>
|
||||
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
|
||||
grants.some(
|
||||
(grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime())
|
||||
);
|
||||
|
||||
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
|
||||
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
|
||||
@@ -67,9 +69,7 @@ const resolveSpecialAccess = (options: {
|
||||
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
|
||||
const expiresAt = active.some((grant) => !grant.expiresAt)
|
||||
? null
|
||||
: active
|
||||
.map((grant) => grant.expiresAt!)
|
||||
.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
||||
: (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null);
|
||||
return {
|
||||
kind: selected.kind,
|
||||
grantId: selected.id,
|
||||
@@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
||||
'localAccountGeneralCreationGraceDays',
|
||||
generalCreationDefault
|
||||
);
|
||||
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
|
||||
const kakaoVerified =
|
||||
options.user.oauthType === 'KAKAO' &&
|
||||
Boolean(options.user.oauthId?.trim()) &&
|
||||
Boolean(options.user.kakaoVerifiedAt);
|
||||
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
|
||||
const now = options.now ?? new Date();
|
||||
const specialAccess = resolveSpecialAccess({
|
||||
@@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
||||
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
|
||||
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
|
||||
const canCreateGeneral =
|
||||
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
|
||||
kakaoVerified ||
|
||||
specialAccess?.allowsGeneralCreation === true ||
|
||||
(accessAllowed && now < generalCreationEndsAt);
|
||||
|
||||
return {
|
||||
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface OAuthPendingState {
|
||||
export interface OAuthSession {
|
||||
id: string;
|
||||
mode: OAuthMode;
|
||||
intent?: 'register' | 'link_existing' | 'rejoin';
|
||||
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
|
||||
targetUserId?: string;
|
||||
kakaoId: string;
|
||||
email: string;
|
||||
@@ -93,6 +93,14 @@ end
|
||||
return cjson.encode({ status = 'verified', userId = challenge.userId })
|
||||
`;
|
||||
|
||||
const consumeOnceScript = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if raw then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return raw
|
||||
`;
|
||||
|
||||
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly prefix: string;
|
||||
@@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
|
||||
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
||||
const key = this.sessionKey(sessionId);
|
||||
const raw = await this.client.get(key);
|
||||
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
await this.client.del(key);
|
||||
return parseJson<OAuthSession>(raw);
|
||||
return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
|
||||
}
|
||||
|
||||
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
||||
|
||||
@@ -59,6 +59,7 @@ const mapUser = (row: {
|
||||
displayName: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
passwordResetRequired: boolean;
|
||||
roles: GatewayPrisma.JsonValue;
|
||||
sanctions: GatewayPrisma.JsonValue;
|
||||
oauthType: 'NONE' | 'KAKAO';
|
||||
@@ -107,6 +108,7 @@ const mapUser = (row: {
|
||||
deleteAfter: row.deleteAfter?.toISOString(),
|
||||
passwordHash: row.passwordHash,
|
||||
passwordSalt: row.passwordSalt,
|
||||
passwordResetRequired: row.passwordResetRequired,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
||||
legacyGrade: readLegacyGrade(row.legacyData),
|
||||
@@ -250,6 +252,7 @@ export const createPostgresUserRepository = (
|
||||
displayName: input.displayName ?? input.username,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
passwordResetRequired: false,
|
||||
roles: ['user'] satisfies GatewayPrisma.JsonArray,
|
||||
sanctions: {} satisfies GatewayPrisma.JsonObject,
|
||||
oauthType,
|
||||
@@ -274,10 +277,12 @@ export const createPostgresUserRepository = (
|
||||
data: {
|
||||
passwordHash: upgraded.hash,
|
||||
passwordSalt: upgraded.salt,
|
||||
passwordResetRequired: false,
|
||||
},
|
||||
});
|
||||
user.passwordHash = upgraded.hash;
|
||||
user.passwordSalt = upgraded.salt;
|
||||
user.passwordResetRequired = false;
|
||||
}
|
||||
return verified.ok;
|
||||
},
|
||||
@@ -288,6 +293,7 @@ export const createPostgresUserRepository = (
|
||||
data: {
|
||||
passwordHash: next.hash,
|
||||
passwordSalt: next.salt,
|
||||
passwordResetRequired: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface UserRecord {
|
||||
deleteAfter?: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
passwordResetRequired: boolean;
|
||||
createdAt: string;
|
||||
legacyMemberNo?: number;
|
||||
legacyGrade?: number;
|
||||
@@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
picture: user.picture,
|
||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
|
||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt),
|
||||
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user