๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
UnauthorizedException์€ ์ธ์ฆ์ด ํ•„์š”ํ•˜๊ฑฐ๋‚˜ ๊ถŒํ•œ์ด ๋ถ€์กฑํ•œ ๊ฒฝ์šฐ ์‚ฌ์šฉํ•˜๋Š” ์˜ˆ์™ธ์ž…๋‹ˆ๋‹ค. HTTP 401 ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฉฐ, ๋กœ๊ทธ์ธ ํ•„์š”, ์„ธ์…˜ ๋งŒ๋ฃŒ, ๊ถŒํ•œ ๋ถ€์กฑ ๋“ฑ์˜ ์ƒํ™ฉ์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

class UnauthorizedException extends SoException {
  constructor(
    public message = "Unauthorized",
    public 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);
}

๊ด€๋ จ ๋ฌธ์„œ