IAM Operation 도메인 모델
개요
IAM Operation 도메인의 상세한 도메인 모델을 정의합니다. 도메인 이벤트, 엔티티, 열거형, 값 객체, Aggregate, Domain Service, 에러 코드를 포함합니다.
1. 도메인 이벤트
1.1 내부 이벤트 (Internal Events)
IAM Operation 도메인 내 Aggregate에서 발행하는 이벤트:
Account 컨텍스트
// 계정 등록 요청 이벤트
interface AccountRegistrationRequested {
type: 'iam.acount.registration.requested';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
name: string;
phoneNumber: string;
siteId: string;
groupId: string;
hasPrescriptionPermission: boolean;
};
}
// 계정 생성 이벤트
interface AccountCreated {
type: 'iam.account.created';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
email: string;
name: string;
siteId: string;
groupIds: string[];
createdAt: ISO8601;
};
}
// 초대를 통한 계정 생성 이벤트
interface AccountCreatedViaInvitation {
type: 'iam.account.created.via.invitation';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
email: string;
invitationId: string;
siteId: string;
groupIds: string[];
createdAt: ISO8601;
};
}
// 그룹 중복으로 계정 생성 거부 이벤트
interface AccountCreationRejectedDueToDuplicateGroup {
type: 'iam.account.creation.rejected.duplicate.group';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
groupId: string;
reason: string;
};
}
// 비밀번호 변경 요청 이벤트
interface PasswordChangeRequested {
type: 'iam.password.change.requested';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
requestedAt: ISO8601;
};
}
// 현재 비밀번호 검증 실패 이벤트
interface CurrentPasswordVerificationFailed {
type: 'iam.password.current.verification.failed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
failedAt: ISO8601;
};
}
// 비밀번호 변경 이벤트
interface PasswordChanged {
type: 'iam.password.changed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
changedBy: string; // 'self' | 'admin'
changedAt: ISO8601;
};
}
// 현재 비밀번호 불일치로 비밀번호 변경 거부 이벤트
interface PasswordChangeRejectedDueToInvalidCurrentPassword {
type: 'iam.password.change.rejected.invalid.current.password';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
reason: string;
rejectedAt: ISO8601;
};
}
// 강제 비밀번호 초기화 요청 이벤트
interface PasswordForcedResetRequested {
type: 'iam.password.forced.reset.requested';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
requestedBy: string; // admin accountId
requestedAt: ISO8601;
};
}
// 무작위 비밀번호 생성 이벤트
interface RandomPasswordGenerated {
type: 'iam.password.random.generated';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
generatedAt: ISO8601;
};
}
// 비밀번호 강제 초기화 이벤트
interface PasswordForciblyReset {
type: 'iam.password.forcibly.reset';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
resetBy: string; // admin accountId
resetAt: ISO8601;
};
}
// 비밀번호 재설정 이메일 전송 이벤트
interface PasswordResetEmailSent {
type: 'iam.password.reset.email.sent';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
email: string;
sentAt: ISO8601;
};
}
// 비밀번호 재설정 이메일 전송 실패 이벤트
interface PasswordResetEmailFailed {
type: 'iam.password.reset.email.failed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
email: string;
failedAt: ISO8601;
reason: string;
};
}
// 계정 삭제 요청 이벤트
interface AccountDeletionRequested {
type: 'iam.account.deletion.requested';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
requestedBy: string;
requestedAt: ISO8601;
};
}
// 관리자 계정 삭제 가능 여부 평가 이벤트
interface AdminAccountDeletionEvaluated {
type: 'iam.admin.account.deletion.evaluated';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
canDelete: boolean;
evaluatedAt: ISO8601;
};
}
// 계정 삭제 이벤트
interface AccountDeleted {
type: 'iam.account.deleted';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
deletedBy: string;
deletedAt: ISO8601;
wasLastAccount: boolean;
};
}
// 비밀번호 불일치로 계정 삭제 거부 이벤트
interface AccountDeletionRejectedDueToPasswordMismatch {
type: 'iam.account.deletion.rejected.password.mismatch';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
reason: string;
rejectedAt: ISO8601;
};
}
// 권한 없는 사용자의 삭제 시도 거부 이벤트
interface AccountDeletionRejectedDueToUnauthorizedUser {
type: 'iam.account.deletion.rejected.unauthorized.user';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
requestedBy: string;
reason: string;
rejectedAt: ISO8601;
};
}
// 마지막 관리자 삭제 시도 거부 이벤트
interface AccountDeletionRejectedDueToLastAdmin {
type: 'iam.account.deletion.rejected.last.admin';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
siteId: string;
reason: string;
rejectedAt: ISO8601;
};
}
// 마지막 계정과 함께 사이트 삭제 이벤트
interface SiteDeletedWithLastAccount {
type: 'iam.site.deleted.with.last.account';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
siteId: string;
accountId: string;
deletedAt: ISO8601;
};
}
LoginAttempt 컨텍스트
// 로그인 시도 이벤트
interface LoginAttempted {
type: 'iam.login.attempted';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
attemptedAt: ISO8601;
ipAddress: string;
};
}
// 비밀번호 검증 실패 이벤트
interface PasswordVerificationFailed {
type: 'iam.password.verification.failed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
failedAt: ISO8601;
};
}
// 로그인 성공 이벤트
interface LoginSucceeded {
type: 'iam.login.succeeded';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
email: string;
succeededAt: ISO8601;
ipAddress: string;
};
}
// 로그인 실패 이벤트
interface LoginFailed {
type: 'iam.login.failed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
failedAt: ISO8601;
reason: string;
};
}
// 로그인 시도 기록 이벤트
interface LoginAttemptRecorded {
type: 'iam.login.attempt.recorded';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
attemptCount: number;
recordedAt: ISO8601;
};
}
// 로그인 시도 횟수 초기화 이벤트
interface LoginAttemptCountReset {
type: 'iam.login.attempt.count.reset';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
resetAt: ISO8601;
};
}
// 계정 잠금 이벤트
interface AccountLocked {
type: 'iam.account.locked';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
lockedAt: ISO8601;
unlockAt: ISO8601; // 1분 후
reason: 'consecutive_failures';
};
}
// 잠긴 계정으로 로그인 시도 이벤트
interface AccountLockedLoginAttempted {
type: 'iam.account.locked.login.attempted';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
attemptedAt: ISO8601;
unlockAt: ISO8601;
};
}
Invitation 컨텍스트
// 초대 검증 성공 이벤트
interface InvitationValidated {
type: 'iam.invitation.validated';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
invitationId: string;
email: string;
validatedAt: ISO8601;
};
}
// 초대 검증 실패 이벤트
interface InvitationValidationFailed {
type: 'iam.invitation.validation.failed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
invitationId: string;
reason: 'expired' | 'already_used' | 'not_found';
failedAt: ISO8601;
};
}
// 초대 사용 완료 이벤트
interface InvitationMarkedAsUsed {
type: 'iam.invitation.marked.as.used';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
invitationId: string;
accountId: string;
usedAt: ISO8601;
};
}
SecurityCode 컨텍스트
// 보안 코드 요청 이벤트
interface PasswordResetSecurityCodeRequested {
type: 'iam.security.code.requested';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
requestedAt: ISO8601;
};
}
// 보안 코드 캐시 저장 이벤트
interface SecurityCodeStoredInCache {
type: 'iam.security.code.stored';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
expiresAt: ISO8601;
storedAt: ISO8601;
};
}
// 보안 코드 이메일 전송 이벤트
interface SecurityCodeEmailSent {
type: 'iam.security.code.email.sent';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
sentAt: ISO8601;
};
}
// 보안 코드 검증 요청 이벤트
interface SecurityCodeVerificationRequested {
type: 'iam.security.code.verification.requested';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
requestedAt: ISO8601;
};
}
// 보안 코드 검증 성공 이벤트
interface SecurityCodeValidated {
type: 'iam.security.code.validated';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
validatedAt: ISO8601;
};
}
// 보안 코드 검증 실패 이벤트
interface SecurityCodeValidationFailed {
type: 'iam.security.code.validation.failed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
email: string;
reason: 'invalid_code' | 'expired' | 'not_found';
failedAt: ISO8601;
};
}
Token 컨텍스트
// 토큰 검증 성공 이벤트
interface TokenValidated {
type: 'iam.token.validated';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
tokenType: 'access' | 'refresh' | 'temporary';
validatedAt: ISO8601;
};
}
// 토큰 검증 실패 이벤트
interface TokenValidationFailed {
type: 'iam.token.validation.failed';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
reason: 'invalid_signature' | 'expired' | 'invalid_domain';
failedAt: ISO8601;
};
}
// 액세스 토큰 발급 이벤트
interface AccessTokenIssued {
type: 'iam.token.access.issued';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
expiresAt: ISO8601;
issuedAt: ISO8601;
};
}
// 리프레시 토큰 발급 이벤트
interface RefreshTokenIssued {
type: 'iam.token.refresh.issued';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
expiresAt: ISO8601;
issuedAt: ISO8601;
};
}
// 임시 액세스 토큰 발급 이벤트
interface TemporaryAccessTokenIssued {
type: 'iam.token.temporary.issued';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
expiresAt: ISO8601; // 30분 후
issuedAt: ISO8601;
purpose: 'password_reset';
};
}
// 임시 토큰으로 비밀번호 재설정 이벤트
interface PasswordResetWithTemporaryToken {
type: 'iam.password.reset.with.temporary.token';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
resetAt: ISO8601;
};
}
// 동일한 비밀번호로 재설정 거부 이벤트
interface PasswordResetRejectedDueToDuplicatePassword {
type: 'iam.password.reset.rejected.duplicate.password';
eventId: string;
timestamp: ISO8601;
source: 'iam-medi';
payload: {
accountId: string;
reason: string;
rejectedAt: ISO8601;
};
}
2. 엔티티 (Entities)
Operation Users
id: 식별자email: 계정 이메일name: 이름passwordHash: 비밀번호 해시값status: 상태 (UserStatus)userType: 유저 타입 (UserType)mfaEnabled: 다중인증 활성화 여부mfaSecret: 다중인증 비밀 값lastLoginAt: 마지막 로그인 일자failedLoginAttempts: 로그인 실패 횟수lockoutUntil: 잠금 시간isSuspended: 정지 여부expiresAt: 만료 시간isAnonymized: 익명화 여부anonymizedAt: 익명화 시간anonymizationId: 익명화 IDdataRetentionEndDate: 데이터 보존 종료일deletedAt: 계정 삭제일createdAt: 생성일시updatedAt: 수정일시
2.1 Account (Aggregate Root)
class Account {
// Identity
id: string;
email: Email;
// Profile
name: string;
phoneNumber: PhoneNumber;
hashedPassword: HashedPassword;
// Site & Group
siteId?: string;
groupIds: string[];
hasPrescriptionPermission: boolean;
// 생명주기 관리
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
2.2 LoginAttempt (Entity)
class LoginAttempt {
userId: string;
// Identity
email: Email;
// Attempt tracking
attemptCount: number;
lastAttemptAt: Date;
// Lock status
isLocked: boolean;
lockedAt?: Date;
unlockAt?: Date;
// 생명주기 관리
createdAt: Date;
updatedAt: Date;
}
2.3 Invitation (Entity)
class Invitation {
// Identity
id: InvitationUUID;
// Invitation info
email: Email;
siteId: string;
groupIds: string[];
hasPrescriptionPermission: boolean;
// Usage status
isUsed: boolean;
usedBy?: string; // accountId
usedAt?: Date;
// Expiration
expiresAt: Date;
// MEDI
authorized_to_prescribe?: boolean;
// 생명주기 관리
createdAt: Date;
updatedAt: Date;
}
2.4 RefreshToken (Entity)
class RefreshToken {
// Identity
id: string;
userId: string;
// Token info
token: string;
expiresAt: Date;
// 생명주기 관리
createdAt: Date;
revokedAt?: Date;
}
3. 열거형 (Enums)
UserStatus
ACTIVE: 활성INACTIVE: 비활성LOCKED: 잠금PENDING: 보류SUSPENDED: 정지BANNED: 접근금지
UserType
OPERATION_USER: 내부 운영자SERVICE_ACCOUNT: 서비스 계정
3.1 토큰 타입
enum TokenType {
Access = 'access', // 액세스 토큰
Refresh = 'refresh', // 리프레시 토큰
Temporary = 'temporary', // 임시 토큰 (비밀번호 재설정용)
}
3.2 그룹 타입
enum GroupType {
PlatformAdmin = 'medi.platform-admin', // 사내 운영자
SiteAdmin = 'medi.site-admin', // 사이트 관리자
SiteMember = 'medi.site-member', // 사이트 구성원
}
4. 값 객체 (Value Objects)
4.1 PhoneNumber
export class PhoneNumber extends ValueObject<{ value: string }> {
private constructor(value: string) {
super({ value });
}
/**
* 전화번호 생성
*
* 도메인 규칙:
* - 공백과 하이픈 자동 제거
* - 숫자만 허용
* - 최소 8자리, 최대 15자리
*/
static create(phoneNumber: string): PhoneNumber {
if (!phoneNumber || phoneNumber.trim().length === 0) {
throw new InvalidPhoneNumberError('Phone number cannot be empty');
}
// 전화번호 정규화 (공백, 하이픈 제거)
const sanitized = phoneNumber.replace(/[\s-]/g, '');
// 전화번호 유효성 검증 (숫자만)
const phoneRegex = /^\d+$/;
if (!phoneRegex.test(sanitized)) {
throw new InvalidPhoneNumberError('Phone number must contain only digits');
}
if (sanitized.length < 8 || sanitized.length > 15) {
throw new InvalidPhoneNumberError('Phone number must be between 8 and 15 digits');
}
return new PhoneNumber(sanitized);
}
get value(): string {
return this.props.value;
}
// VO 자체의 비즈니스 로직
getFormatted(): string {
// 독일 전화번호 형식 (+49 XXX XXXXXXX)
if (this.value.startsWith('49') && this.value.length >= 11) {
const areaCode = this.value.slice(2, 5);
const number = this.value.slice(5);
return `+49 ${areaCode} ${number}`;
}
return this.value;
}
getCountryCode(): string | null {
if (this.value.startsWith('49')) return '+49'; // 독일
if (this.value.startsWith('82')) return '+82'; // 한국
if (this.value.startsWith('1')) return '+1'; // 미국/캐나다
return null;
}
protected equalsCore(other: PhoneNumber): boolean {
return this.value === other.value;
}
}
4.3 HashedPassword
export class HashedPassword extends ValueObject<{ value: string }> {
private constructor(value: string) {
super({ value });
}
/**
* Bcrypt 해시 비밀번호 생성
*
* 도메인 규칙:
* - Bcrypt 표준 포맷 ($2a$ 또는 $2b$로 시작)
* - 60자 고정 길이
*/
static create(hashedValue: string): HashedPassword {
if (!hashedValue || hashedValue.trim().length === 0) {
throw new InvalidPasswordHashFormatError('Hashed password cannot be empty');
}
// bcrypt 표준 포맷 검증 ($2a$ 또는 $2b$로 시작)
if (!hashedValue.startsWith('$2a$') && !hashedValue.startsWith('$2b$')) {
throw new InvalidPasswordHashFormatError('Invalid bcrypt hash format');
}
if (hashedValue.length !== 60) {
throw new InvalidPasswordHashFormatError('Bcrypt hash must be exactly 60 characters');
}
return new HashedPassword(hashedValue);
}
get value(): string {
return this.props.value;
}
// VO 자체의 비즈니스 로직
getBcryptVersion(): string {
return this.value.startsWith('$2a$') ? '2a' : '2b';
}
getCostFactor(): number {
// $2a$10$ → cost factor는 10
const match = this.value.match(/^\$2[ab]\$(\d{2})\$/);
return match ? parseInt(match[1], 10) : 10;
}
getSalt(): string {
// Bcrypt 해시에서 salt 부분 추출 (22자)
return this.value.substring(7, 29);
}
protected equalsCore(other: HashedPassword): boolean {
return this.value === other.value;
}
}
4.4 SecurityCode
export class SecurityCode extends ValueObject<{ code: string }> {
private constructor(code: string) {
super({ code });
}
/**
* 보안 코드 생성 (6자리)
*
* 도메인 규칙:
* - 정확히 6자리 숫자
* - 000000 ~ 999999 범위
*/
static create(code: string): SecurityCode {
// 6자리 숫자 검증
const codeRegex = /^\d{6}$/;
if (!codeRegex.test(code)) {
throw new InvalidSecurityCodeFormatError('Security code must be exactly 6 digits');
}
return new SecurityCode(code);
}
/**
* 무작위 6자리 보안 코드 생성
*/
static generate(): SecurityCode {
const code = Math.floor(100000 + Math.random() * 900000).toString();
return new SecurityCode(code);
}
get value(): string {
return this.props.code;
}
// VO 자체의 비즈니스 로직
matches(inputCode: string): boolean {
// Constant-time 비교 (타이밍 공격 방지)
return this.value === inputCode;
}
getObfuscated(): string {
// 처음 3자리 마스킹 (예: 123456 → ***456)
return `***${this.value.slice(-3)}`;
}
isWeak(): boolean {
// 반복된 숫자 체크 (예: 111111, 000000)
const firstDigit = this.value[0];
return this.value.split('').every((digit) => digit === firstDigit);
}
protected equalsCore(other: SecurityCode): boolean {
return this.value === other.value;
}
}
4.5 InvitationUUID
export class InvitationUUID extends ValueObject<{ value: string }> {
private constructor(value: string) {
super({ value });
}
/**
* 초대 UUID 생성
*
* 도메인 규칙:
* - UUID v4 형식 (예: 550e8400-e29b-41d4-a716-446655440000)
* - 소문자 헥사 문자만 허용
*/
static create(uuid?: string): InvitationUUID {
if (!uuid) {
// UUID가 없으면 새로운 UUID 생성
uuid = crypto.randomUUID();
}
// UUID v4 형식 검증
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(uuid)) {
throw new InvalidInvitationUUIDError('Invalid UUID v4 format');
}
return new InvitationUUID(uuid.toLowerCase());
}
get value(): string {
return this.props.value;
}
// VO 자체의 비즈니스 로직
getShortId(): string {
// 처음 8자리만 반환 (로그 등에서 사용)
return this.value.slice(0, 8);
}
getVersion(): number {
// UUID 버전 추출 (v4인 경우 4 반환)
return parseInt(this.value.charAt(14), 16);
}
getVariant(): string {
// UUID variant 추출
const variantChar = this.value.charAt(19);
if (['8', '9', 'a', 'b'].includes(variantChar)) {
return 'RFC 4122';
}
return 'Unknown';
}
protected equalsCore(other: InvitationUUID): boolean {
return this.value === other.value;
}
}
5. Aggregates
5.1 Account
Root Entity: Account
책임:
- 계정 생명주기 관리 (생성, 수정, 삭제)
- 비밀번호 관리 (변경, 검증, 강제 초기화)
- 그룹 및 사이트 소속 관리
class Account {
// Identity
id: string;
email: Email;
// Profile
name: string;
phoneNumber: PhoneNumber;
hashedPassword: HashedPassword;
// Site & Group
siteId?: string;
groupIds: string[];
hasPrescriptionPermission: boolean;
// 생명주기
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
// Methods
methods:
- static create(
email: Email,
name: string,
phoneNumber: PhoneNumber,
rawPassword: string,
siteId: string,
groupId: string,
hasPrescriptionPermission: boolean
): Account
// 비밀번호 해싱 (클라이언트 해시 + SERVER_PEPPER + bcrypt)
- static createViaInvitation(
email: Email,
name: string,
phoneNumber: PhoneNumber,
rawPassword: string,
invitation: Invitation
): Account
// 초대 정보에서 사이트/그룹 정보 추출
- changePassword(
currentPassword: string,
newPassword: string
): void
// 현재 비밀번호 검증 후 변경
- forceResetPassword(
newRandomPassword: string,
adminId: string
): void
// 관리자에 의한 강제 초기화
- resetPasswordWithTemporaryToken(
newPassword: string
): void
// 보안 코드 검증 후 비밀번호 재설정
- verifyPassword(rawPassword: string): boolean
// 비밀번호 검증 (constant-time 비교)
- isDuplicateGroup(groupId: string): boolean
// 동일 그룹 중복 가입 여부 확인
- isLastAdminInSite(
siteAccountCount: number,
isAdmin: boolean
): boolean
// 마지막 관리자 여부 확인
- delete(password: string, requesterId: string): void
// 계정 삭제 (비밀번호 검증 + 권한 검증)
// Query Methods
query:
- getEmail(): Email
- getGroups(): string[]
- getSiteId(): string
- hasPrescriptionPermission(): boolean
- isDeleted(): boolean
// Events Published
events:
- AccountRegistrationRequested
- AccountCreated
- AccountCreatedViaInvitation
- AccountCreationRejectedDueToDuplicateGroup
- PasswordChangeRequested
- CurrentPasswordVerificationFailed
- PasswordChanged
- PasswordChangeRejectedDueToInvalidCurrentPassword
- PasswordForcedResetRequested
- RandomPasswordGenerated
- PasswordForciblyReset
- PasswordResetEmailSent
- PasswordResetEmailFailed
- PasswordResetWithTemporaryToken
- PasswordResetRejectedDueToDuplicatePassword
- AccountDeletionRequested
- AdminAccountDeletionEvaluated
- AccountDeleted
- AccountDeletionRejectedDueToPasswordMismatch
- AccountDeletionRejectedDueToUnauthorizedUser
- AccountDeletionRejectedDueToLastAdmin
- SiteDeletedWithLastAccount
// Repository
interface IAccountRepository {
save(account: Account): Promise<void>;
findById(id: string): Promise<Account | null>;
findByEmail(email: Email): Promise<Account | null>;
findByEmailAndGroupId(email: Email, groupId: string): Promise<Account | null>;
countBySiteId(siteId: string): Promise<number>;
countAdminsBySiteId(siteId: string): Promise<number>;
delete(accountId: string): Promise<void>;
}
}
5.2 LoginAttempt
Root Entity: LoginAttempt
책임:
- 로그인 시도 추적
- 실패 횟수 관리
- 계정 잠금 상태 관리
class LoginAttempt {
// Identity
email: Email;
// Attempt tracking
attemptCount: number;
lastAttemptAt: Date;
// Lock status
isLocked: boolean;
lockedAt?: Date;
unlockAt?: Date;
// 생명주기
createdAt: Date;
updatedAt: Date;
// Methods
methods:
- recordFailure(currentTime: Date): void
// 실패 횟수 증가, 5회 도달 시 계정 잠금
- resetAttempts(): void
// 로그인 성공 시 실패 횟수 초기화
- checkIfLocked(currentTime: Date): boolean
// 잠금 상태 확인 (시간 경과 시 자동 해제)
- lock(currentTime: Date): void
// 계정 잠금 (1분간)
- unlock(): void
// 계정 잠금 해제
// Query Methods
query:
- getAttemptCount(): number
- isAccountLocked(currentTime: Date): boolean
- getUnlockTime(): Date | null
// Events Published
events:
- LoginAttempted
- PasswordVerificationFailed
- LoginSucceeded
- LoginFailed
- LoginAttemptRecorded
- LoginAttemptCountReset
- AccountLocked
- AccountLockedLoginAttempted
// Repository
interface ILoginAttemptRepository {
save(loginAttempt: LoginAttempt): Promise<void>;
findByEmail(email: Email): Promise<LoginAttempt | null>;
}
}
5.3 Invitation
Root Entity: Invitation
책임:
- 초대 생성 및 관리
- 초대 유효성 검증
- 초대 사용 완료 처리
class Invitation {
// Identity
id: InvitationUUID;
// Invitation info
email: Email;
siteId: string;
groupIds: string[];
hasPrescriptionPermission: boolean;
// Usage status
isUsed: boolean;
usedBy?: string;
usedAt?: Date;
// Expiration
expiresAt: Date;
// 생명주기
createdAt: Date;
updatedAt: Date;
// Methods
methods:
- static create(
email: Email,
siteId: string,
groupIds: string[],
hasPrescriptionPermission: boolean,
validityDays: number
): Invitation
// 초대 생성 (만료 시간 자동 설정)
- validate(currentTime: Date): void
// 초대 유효성 검증 (만료, 사용 여부)
- markAsUsed(accountId: string, currentTime: Date): void
// 초대 사용 완료 처리 (단일 트랜잭션)
// Query Methods
query:
- isValid(currentTime: Date): boolean
- isExpired(currentTime: Date): boolean
- isUsed(): boolean
- getEmail(): Email
- getSiteId(): string
- getGroupIds(): string[]
// Events Published
events:
- InvitationValidated
- InvitationValidationFailed
- InvitationMarkedAsUsed
// Repository
interface IInvitationRepository {
save(invitation: Invitation): Promise<void>;
findById(id: InvitationUUID): Promise<Invitation | null>;
update(invitation: Invitation): Promise<void>;
}
}
5.4 SecurityCode
Aggregate Root: SecurityCode (캐시 기반)
책임:
- 비밀번호 재설정용 보안 코드 생성
- 보안 코드 검증
- 임시 액세스 토큰 발급
class SecurityCode {
// Identity
email: Email;
code: SecurityCode; // Value Object
// Expiration
expiresAt: Date;
// 생성 시간
createdAt: Date;
// Methods
methods:
- static generate(
email: Email,
ttlMinutes: number
): SecurityCode
// 6자리 코드 생성 (중복 검증)
- verify(inputCode: string, currentTime: Date): boolean
// 보안 코드 검증 (일치 여부 + 만료 여부)
- isExpired(currentTime: Date): boolean
// 만료 여부 확인
// Query Methods
query:
- getCode(): string
- getEmail(): Email
- getExpiresAt(): Date
// Events Published
events:
- PasswordResetSecurityCodeRequested
- SecurityCodeStoredInCache
- SecurityCodeEmailSent
- SecurityCodeVerificationRequested
- SecurityCodeValidated
- SecurityCodeValidationFailed
// Cache Repository
interface ISecurityCodeCacheRepository {
save(securityCode: SecurityCode, ttl: number): Promise<void>;
findByEmail(email: Email): Promise<SecurityCode | null>;
delete(email: Email): Promise<void>;
}
}
5.5 Token
Root Entity: Token
책임:
- JWT 토큰 발급 (액세스, 리프레시, 임시)
- 토큰 검증 (시그니처, 만료, 도메인)
- 토큰 페이로드 구성
class Token {
// Token info
accountId: string;
accountType: AccountType;
tokenType: TokenType;
domain?: string;
// Expiration
issuedAt: Date;
expiresAt: Date;
// JWT string
token: string;
// Methods
methods:
- static issueAccessToken(
accountId: string,
accountType: AccountType,
domain?: string,
ttl: number
): Token
// 액세스 토큰 발급
- static issueRefreshToken(
accountId: string,
ttl: number
): Token
// 리프레시 토큰 발급
- static issueTemporaryToken(
accountId: string,
ttl: number = 1800 // 30분
): Token
// 임시 액세스 토큰 발급 (비밀번호 재설정용)
- validate(currentTime: Date): void
// 토큰 검증 (시그니처, 만료)
- validateDomain(expectedDomain: string): void
// 도메인 검증 (일반 사용자의 액세스 토큰만)
- isExpired(currentTime: Date): boolean
// 만료 여부 확인
// Query Methods
query:
- getAccountId(): string
- getAccountType(): AccountType
- getTokenType(): TokenType
- getDomain(): string | undefined
- getToken(): string
// Events Published
events:
- TokenValidated
- TokenValidationFailed
- AccessTokenIssued
- RefreshTokenIssued
- TemporaryAccessTokenIssued
// Repository (리프레시 토큰만 저장)
interface ITokenRepository {
saveRefreshToken(token: Token): Promise<void>;
findRefreshToken(accountId: string): Promise<Token | null>;
revokeRefreshToken(accountId: string): Promise<void>;
}
}
6. Domain Services
6.1 PasswordHashingService
class PasswordHashingService {
// 비밀번호 해싱
async hashPassword(clientHashedPassword: string, serverPepper: string): Promise<HashedPassword> {
// 클라이언트 해시 + SERVER_PEPPER 결합 후 bcrypt 재해싱
const combined = clientHashedPassword + serverPepper;
const bcryptHash = await bcrypt.hash(combined, 10);
return new HashedPassword(bcryptHash);
}
// 비밀번호 검증
async verifyPassword(clientHashedPassword: string, serverPepper: string, storedHash: HashedPassword): Promise<boolean> {
// constant-time 비교
const combined = clientHashedPassword + serverPepper;
return await bcrypt.compare(combined, storedHash.value);
}
}
6.2 DuplicateGroupValidationService
class DuplicateGroupValidationService {
// 그룹 중복 검증
async validateNoDuplicateGroup(email: Email, groupId: string, accountRepository: IAccountRepository): Promise<void> {
// 동일한 이메일로 같은 그룹에 이미 가입되어 있는지 확인
const existingAccount = await accountRepository.findByEmailAndGroupId(email, groupId);
if (existingAccount) {
throw new DuplicateGroupMembershipError(email.value, groupId);
}
}
}
6.3 LastAdminProtectionService
class LastAdminProtectionService {
// 마지막 관리자 보호
async canDeleteAdmin(account: Account, accountRepository: IAccountRepository): Promise<boolean> {
// 관리자가 아니면 삭제 가능
const isAdmin = account.groupIds.some((g) => g === GroupType.PlatformAdmin || g === GroupType.SiteAdmin);
if (!isAdmin) return true;
// 사이트의 다른 계정 존재 여부 확인
const siteAccountCount = await accountRepository.countBySiteId(account.siteId);
if (siteAccountCount === 1) return true; // 유일한 계정이면 삭제 가능 (사이트도 함께 삭제)
// 다른 계정이 있는 경우, 다른 관리자 존재 여부 확인
const adminCount = await accountRepository.countAdminsBySiteId(account.siteId);
return adminCount > 1; // 다른 관리자가 있으면 삭제 가능
}
}
6.4 InvitationValidationService
class InvitationValidationService {
// 초대 유효성 검증
validateInvitation(invitation: Invitation, currentTime: Date): void {
// 1. 초대 존재 확인 (Repository에서 확인)
// 2. 만료 여부 확인
if (invitation.isExpired(currentTime)) {
throw new InvitationExpiredError(invitation.id.value);
}
// 3. 사용 여부 확인
if (invitation.isUsed()) {
throw new InvitationAlreadyUsedError(invitation.id.value);
}
}
}
6.5 SecurityCodeGenerationService
class SecurityCodeGenerationService {
// 고유한 보안 코드 생성
async generateUniqueCode(email: Email, cacheRepository: ISecurityCodeCacheRepository): Promise<SecurityCode> {
let code: SecurityCode;
let attempts = 0;
const maxAttempts = 10;
do {
code = SecurityCode.generate();
const existing = await cacheRepository.findByEmail(email);
if (!existing || existing.code !== code.code) {
break;
}
attempts++;
} while (attempts < maxAttempts);
if (attempts >= maxAttempts) {
throw new SecurityCodeGenerationFailedError();
}
return code;
}
}
7. Error Codes
7.1 클라이언트 에러 (4xxx)
// 4001: 이메일 형식 오류
class InvalidEmailFormatError extends DomainException {
code = 'IAM-4001';
httpStatus = 400;
message = 'Invalid email format';
}
// 4002: 비밀번호 길이 오류
class InvalidPasswordLengthError extends DomainException {
code = 'IAM-4002';
httpStatus = 400;
message = 'Password must be between 10 and 20 characters';
}
// 4003: 비밀번호 복잡도 오류
class InvalidPasswordComplexityError extends DomainException {
code = 'IAM-4003';
httpStatus = 400;
message = 'Password must contain at least 2 types of: numbers, letters, special characters';
}
// 4004: 전화번호 형식 오류
class InvalidPhoneNumberError extends DomainException {
code = 'IAM-4004';
httpStatus = 400;
message = 'Invalid phone number format';
}
// 4005: 중복 그룹 가입
class DuplicateGroupMembershipError extends DomainException {
code = 'IAM-4005';
httpStatus = 409;
message = 'Account already exists in this group';
}
// 4006: 초대 만료
class InvitationExpiredError extends DomainException {
code = 'IAM-4006';
httpStatus = 400;
message = 'Invitation has expired';
}
// 4007: 초대 이미 사용됨
class InvitationAlreadyUsedError extends DomainException {
code = 'IAM-4007';
httpStatus = 400;
message = 'Invitation has already been used';
}
// 4008: 초대를 찾을 수 없음
class InvitationNotFoundError extends DomainException {
code = 'IAM-4008';
httpStatus = 404;
message = 'Invitation not found';
}
// 4009: 비밀번호 불일치
class PasswordMismatchError extends DomainException {
code = 'IAM-4009';
httpStatus = 401;
message = 'Invalid email or password';
}
// 4010: 계정 잠김
class AccountLockedError extends DomainException {
code = 'IAM-4010';
httpStatus = 403;
message = 'Account is locked due to multiple failed login attempts';
}
// 4011: 보안 코드 불일치
class SecurityCodeMismatchError extends DomainException {
code = 'IAM-4011';
httpStatus = 400;
message = 'Invalid security code';
}
// 4012: 보안 코드 만료
class SecurityCodeExpiredError extends DomainException {
code = 'IAM-4012';
httpStatus = 400;
message = 'Security code has expired';
}
// 4013: 동일한 비밀번호
class SamePasswordError extends DomainException {
code = 'IAM-4013';
httpStatus = 400;
message = 'New password must be different from current password';
}
// 4014: 토큰 시그니처 오류
class InvalidTokenSignatureError extends DomainException {
code = 'IAM-4014';
httpStatus = 401;
message = 'Invalid token signature';
}
// 4015: 토큰 만료
class TokenExpiredError extends DomainException {
code = 'IAM-4015';
httpStatus = 401;
message = 'Token has expired';
}
// 4016: 도메인 불일치
class InvalidTokenDomainError extends DomainException {
code = 'IAM-4016';
httpStatus = 403;
message = 'Token domain does not match';
}
// 4017: 계정을 찾을 수 없음
class AccountNotFoundError extends DomainException {
code = 'IAM-4017';
httpStatus = 404;
message = 'Account not found';
}
// 4018: 권한 없음 (계정 삭제)
class UnauthorizedAccountDeletionError extends DomainException {
code = 'IAM-4018';
httpStatus = 403;
message = 'You are not authorized to delete this account';
}
// 4019: 마지막 관리자 삭제 시도
class LastAdminDeletionError extends DomainException {
code = 'IAM-4019';
httpStatus = 409;
message = 'Cannot delete the last administrator of the site';
}
// 4020: 비밀번호 해시 형식 오류
class InvalidPasswordHashFormatError extends DomainException {
code = 'IAM-4020';
httpStatus = 400;
message = 'Invalid password hash format';
}
7.2 서버 에러 (5xxx)
// 5001: 계정 생성 실패
class AccountCreationFailedError extends DomainException {
code = 'IAM-5001';
httpStatus = 500;
message = 'Failed to create account';
}
// 5002: 비밀번호 해싱 실패
class PasswordHashingFailedError extends DomainException {
code = 'IAM-5002';
httpStatus = 500;
message = 'Failed to hash password';
}
// 5003: 토큰 발급 실패
class TokenIssuanceFailedError extends DomainException {
code = 'IAM-5003';
httpStatus = 500;
message = 'Failed to issue token';
}
// 5004: 이메일 전송 실패
class EmailSendingFailedError extends DomainException {
code = 'IAM-5004';
httpStatus = 500;
message = 'Failed to send email';
}
// 5005: 보안 코드 생성 실패
class SecurityCodeGenerationFailedError extends DomainException {
code = 'IAM-5005';
httpStatus = 500;
message = 'Failed to generate security code';
}
// 5006: 데이터 저장 실패
class DataPersistenceFailedError extends DomainException {
code = 'IAM-5006';
httpStatus = 500;
message = 'Failed to persist data to database';
}
// 5007: 이벤트 발행 실패
class EventPublishingFailedError extends DomainException {
code = 'IAM-5007';
httpStatus = 500;
message = 'Failed to publish domain event';
}
변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.57.0 | 2025-12-15 | dalia@weltcorp.com | 도메인 모델 정의 |