메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
UnauthorizedException은 인증이 ν•„μš”ν•˜κ±°λ‚˜ κΆŒν•œμ΄ λΆ€μ‘±ν•œ 경우 μ‚¬μš©ν•˜λŠ” μ˜ˆμ™Έμž…λ‹ˆλ‹€. HTTP 401 μƒνƒœ μ½”λ“œλ₯Ό λ°˜ν™˜ν•˜λ©°, 둜그인 ν•„μš”, μ„Έμ…˜ 만료, κΆŒν•œ λΆ€μ‘± λ“±μ˜ 상황에 μ‚¬μš©λ©λ‹ˆλ‹€.

κΈ°λ³Έ μ‚¬μš©λ²•

class UnauthorizedException extends SoException {
  constructor(message: LocalizedString, payload?: unknown);
}
κ°„λ‹¨ν•œ μ˜ˆμ‹œ:
@api()
async getMyProfile(ctx: Context) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  return this.findById(ctx.user.id);
}

μ‹€μš© 예제

기본 인증 체크

@api()
async updateMyProfile(
  ctx: Context,
  name: string,
  bio: string
) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  return this.update(ctx.user.id, { name, bio });
}

Guardsλ₯Ό μ‚¬μš©ν•œ 인증 (ꢌμž₯)

κ°„λ‹¨ν•œ 둜그인 μ²΄ν¬λŠ” Guardsλ₯Ό μ‚¬μš©ν•˜λŠ” 것이 더 κΉ”λ”ν•©λ‹ˆλ‹€:
// Guards μ‚¬μš© (ꢌμž₯)
@api({ guards: ["user"] })
async getMyData(ctx: Context) {
  // ctx.userκ°€ 보μž₯됨
  return this.findById(ctx.user!.id);
}

// sonamu.config.tsμ—μ„œ Guard 처리
export default {
  server: {
    apiConfig: {
      guardHandler: (guard, request, api) => {
        if (guard === "user" && !request.user) {
          throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
        }
        return true;
      }
    }
  }
} satisfies SonamuConfig;

λ¦¬μ†ŒμŠ€ μ†Œμœ κΆŒ 검증

@api()
async deletePost(ctx: Context, postId: number) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  const post = await this.findById(postId);

  if (!post) {
    throw new NotFoundException("κ²Œμ‹œλ¬Όμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€");
  }

  // 본인이 μž‘μ„±ν•œ κ²Œμ‹œλ¬Όλ§Œ μ‚­μ œ κ°€λŠ₯
  if (post.authorId !== ctx.user.id) {
    throw new UnauthorizedException(
      "본인이 μž‘μ„±ν•œ κ²Œμ‹œλ¬Όλ§Œ μ‚­μ œν•  수 μžˆμŠ΅λ‹ˆλ‹€",
      { postId, authorId: post.authorId }
    );
  }

  return this.delete(postId);
}

μ—­ν•  기반 κΆŒν•œ 검증

@api()
async deleteUser(ctx: Context, userId: number) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  // κ΄€λ¦¬μž κΆŒν•œ 체크
  if (!ctx.user.isAdmin) {
    throw new UnauthorizedException(
      "κ΄€λ¦¬μžλ§Œ μ‚¬μš©μžλ₯Ό μ‚­μ œν•  수 μžˆμŠ΅λ‹ˆλ‹€",
      { requiredRole: "admin", currentRole: ctx.user.role }
    );
  }

  // 자기 μžμ‹ μ€ μ‚­μ œ λΆˆκ°€
  if (ctx.user.id === userId) {
    throw new BadRequestException("자기 μžμ‹ μ€ μ‚­μ œν•  수 μ—†μŠ΅λ‹ˆλ‹€");
  }

  return this.delete(userId);
}

볡합 κΆŒν•œ 검증

@api()
async publishPost(ctx: Context, postId: number) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  const post = await this.findById(postId);

  if (!post) {
    throw new NotFoundException("κ²Œμ‹œλ¬Όμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€");
  }

  // μž‘μ„±μžμ΄κ±°λ‚˜ νŽΈμ§‘μž κΆŒν•œμ΄ μžˆμ–΄μ•Ό 함
  const isAuthor = post.authorId === ctx.user.id;
  const isEditor = ctx.user.role === "editor" || ctx.user.role === "admin";

  if (!isAuthor && !isEditor) {
    throw new UnauthorizedException(
      "κ²Œμ‹œλ¬Όμ„ λ°œν–‰ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€",
      {
        postId,
        authorId: post.authorId,
        currentUserId: ctx.user.id,
        currentRole: ctx.user.role,
        requiredCondition: "author or editor/admin role"
      }
    );
  }

  return this.update(postId, { status: "published" });
}

쑰직/νŒ€ κΆŒν•œ 검증

@api()
async addTeamMember(
  ctx: Context,
  teamId: number,
  newMemberId: number
) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  const team = await TeamModel.findById(teamId);

  if (!team) {
    throw new NotFoundException("νŒ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€");
  }

  // νŒ€ κ΄€λ¦¬μžλ§Œ 멀버 μΆ”κ°€ κ°€λŠ₯
  const membership = await TeamMemberModel.findOne({
    teamId,
    userId: ctx.user.id
  });

  if (!membership) {
    throw new UnauthorizedException(
      "νŒ€ 멀버가 μ•„λ‹™λ‹ˆλ‹€",
      { teamId }
    );
  }

  if (membership.role !== "admin" && membership.role !== "owner") {
    throw new UnauthorizedException(
      "νŒ€ κ΄€λ¦¬μžλ§Œ 멀버λ₯Ό μΆ”κ°€ν•  수 μžˆμŠ΅λ‹ˆλ‹€",
      {
        teamId,
        currentRole: membership.role,
        requiredRole: "admin or owner"
      }
    );
  }

  return TeamMemberModel.create({
    teamId,
    userId: newMemberId,
    role: "member"
  });
}

API ν‚€ 인증

@api()
async getApiData(ctx: Context) {
  const apiKey = ctx.headers["x-api-key"];

  if (!apiKey) {
    throw new UnauthorizedException(
      "API ν‚€κ°€ ν•„μš”ν•©λ‹ˆλ‹€",
      { header: "x-api-key" }
    );
  }

  const validKey = await ApiKeyModel.findByKey(apiKey);

  if (!validKey) {
    throw new UnauthorizedException(
      "μœ νš¨ν•˜μ§€ μ•Šμ€ API ν‚€μž…λ‹ˆλ‹€"
    );
  }

  if (validKey.expiresAt && validKey.expiresAt < new Date()) {
    throw new UnauthorizedException(
      "만료된 API ν‚€μž…λ‹ˆλ‹€",
      { expiresAt: validKey.expiresAt }
    );
  }

  if (!validKey.isActive) {
    throw new UnauthorizedException(
      "λΉ„ν™œμ„±ν™”λœ API ν‚€μž…λ‹ˆλ‹€",
      { keyId: validKey.id }
    );
  }

  // API 데이터 λ°˜ν™˜
  return this.getDataForApiKey(validKey.id);
}

μ‹œκ°„ 기반 μ ‘κ·Ό μ œμ–΄

@api()
async accessRestrictedResource(ctx: Context, resourceId: number) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  const subscription = await SubscriptionModel.findByUserId(ctx.user.id);

  if (!subscription) {
    throw new UnauthorizedException(
      "ꡬ독이 ν•„μš”ν•œ λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€",
      { resourceId }
    );
  }

  const now = new Date();

  // ꡬ독 만료 체크
  if (subscription.expiresAt < now) {
    throw new UnauthorizedException(
      "ꡬ독이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€",
      {
        expiresAt: subscription.expiresAt,
        renewUrl: "/subscription/renew"
      }
    );
  }

  // ꡬ독 ν”Œλžœλ³„ μ ‘κ·Ό μ œν•œ
  const resource = await ResourceModel.findById(resourceId);

  if (resource.requiredPlan === "premium" && subscription.plan === "basic") {
    throw new UnauthorizedException(
      "프리미엄 ν”Œλžœμ΄ ν•„μš”ν•œ λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€",
      {
        requiredPlan: "premium",
        currentPlan: "basic",
        upgradeUrl: "/subscription/upgrade"
      }
    );
  }

  return resource;
}

IP 기반 μ ‘κ·Ό μ œμ–΄

@api()
async adminOnlyEndpoint(ctx: Context) {
  if (!ctx.user?.isAdmin) {
    throw new UnauthorizedException("κ΄€λ¦¬μž κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€");
  }

  // νŠΉμ • IP λŒ€μ—­μ—μ„œλ§Œ μ ‘κ·Ό ν—ˆμš©
  const allowedIPs = ["192.168.1.0/24", "10.0.0.0/8"];
  const clientIP = ctx.request.ip;

  if (!this.isIPAllowed(clientIP, allowedIPs)) {
    throw new UnauthorizedException(
      "ν—ˆμš©λ˜μ§€ μ•Šμ€ IP μ£Όμ†Œμž…λ‹ˆλ‹€",
      {
        clientIP,
        allowedRanges: allowedIPs
      }
    );
  }

  return this.getAdminData();
}

private isIPAllowed(ip: string, allowedRanges: string[]): boolean {
  // IP λŒ€μ—­ 체크 둜직
  return true; // μ‹€μ œ κ΅¬ν˜„ ν•„μš”
}

Guards와 μˆ˜λ™ 체크 비ꡐ

Guards μ‚¬μš© (ꢌμž₯)

// sonamu.config.ts
guardHandler: (guard, request, api) => {
  if (guard === "user" && !request.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  if (guard === "admin" && !request.user?.isAdmin) {
    throw new UnauthorizedException("κ΄€λ¦¬μž κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€");
  }

  return true;
}

// API λ©”μ„œλ“œ
@api({ guards: ["user"] })
async simpleUserEndpoint(ctx: Context) {
  // 인증 체크가 μžλ™μœΌλ‘œ μ™„λ£Œλ¨
  return this.getData(ctx.user!.id);
}

@api({ guards: ["admin"] })
async adminEndpoint(ctx: Context) {
  // κ΄€λ¦¬μž κΆŒν•œ 체크가 μžλ™μœΌλ‘œ μ™„λ£Œλ¨
  return this.getAdminData();
}

μˆ˜λ™ 체크 (λ³΅μž‘ν•œ 둜직)

@api()
async complexAuthEndpoint(ctx: Context, postId: number) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  const post = await PostModel.findById(postId);

  // λ³΅μž‘ν•œ κΆŒν•œ 둜직: μž‘μ„±μžμ΄κ±°λ‚˜, νŽΈμ§‘μžμ΄κ±°λ‚˜, 같은 νŒ€ 멀버
  const isAuthor = post.authorId === ctx.user.id;
  const isEditor = ctx.user.role === "editor";
  const isSameTeam = await TeamModel.areInSameTeam(
    ctx.user.id,
    post.authorId
  );

  if (!isAuthor && !isEditor && !isSameTeam) {
    throw new UnauthorizedException("μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€");
  }

  return post;
}

payload ν™œμš© νŒ¨ν„΄

둜그인 μœ λ„

throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€", {
  loginUrl: "/auth/login",
  returnUrl: ctx.request.url,
});

κΆŒν•œ μ—…κ·Έλ ˆμ΄λ“œ μœ λ„

throw new UnauthorizedException("프리미엄 κΈ°λŠ₯μž…λ‹ˆλ‹€", {
  requiredPlan: "premium",
  currentPlan: "basic",
  upgradeUrl: "/subscription/upgrade",
  features: ["κ³ κΈ‰ 뢄석", "λ¬΄μ œν•œ ν”„λ‘œμ νŠΈ", "μš°μ„  지원"],
});

만료 정보

throw new UnauthorizedException("μ„Έμ…˜μ΄ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€", {
  expiredAt: session.expiresAt,
  refreshUrl: "/auth/refresh",
});

ν΄λΌμ΄μ–ΈνŠΈ 응닡 μ˜ˆμ‹œ

κΈ°λ³Έ 응닡

{
  "statusCode": 401,
  "message": "둜그인이 ν•„μš”ν•©λ‹ˆλ‹€"
}

payload 포함 응닡

{
  "statusCode": 401,
  "message": "프리미엄 ν”Œλžœμ΄ ν•„μš”ν•œ λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€",
  "payload": {
    "requiredPlan": "premium",
    "currentPlan": "basic",
    "upgradeUrl": "/subscription/upgrade"
  }
}

401 vs 403

  • 401 Unauthorized: 인증 μžμ²΄κ°€ μ•ˆ λ˜μ—ˆκ±°λ‚˜ κΆŒν•œμ΄ λΆ€μ‘±ν•œ 경우
  • 403 Forbidden: 인증은 λ˜μ—ˆμ§€λ§Œ ν•΄λ‹Ή λ¦¬μ†ŒμŠ€μ— μ ‘κ·Όν•  κΆŒν•œμ΄ λͺ…μ‹œμ μœΌλ‘œ μ—†λŠ” 경우
Sonamuμ—μ„œλŠ” UnauthorizedException ν•˜λ‚˜λ‘œ λ‘˜ λ‹€ μ²˜λ¦¬ν•˜μ§€λ§Œ, ν•„μš”ν•˜λ‹€λ©΄ μ»€μŠ€ν…€ ForbiddenException을 λ§Œλ“€ 수 μžˆμŠ΅λ‹ˆλ‹€.
// μ»€μŠ€ν…€ 403 μ˜ˆμ™Έ
export class ForbiddenException extends SoException {
  constructor(
    public message = "Forbidden",
    public payload?: unknown,
  ) {
    super(403, message, payload);
  }
}

// μ‚¬μš©
@api()
async deleteUser(ctx: Context, userId: number) {
  if (!ctx.user) {
    throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  if (!ctx.user.isAdmin) {
    throw new ForbiddenException(
      "이 μž‘μ—…μ„ μˆ˜ν–‰ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€",
      { requiredRole: "admin" }
    );
  }

  return this.delete(userId);
}

κ΄€λ ¨ λ¬Έμ„œ