로그인은 아이디와 비밀번호를 확인하는 기능이 아니다

vx_developer·약 23시간 전

개발하다가

목록 보기
38/39
post-thumbnail

프로그래밍을 처음 배울 때 로그인은 보통 사용자가 입력한 아이디와 비밀번호가 데이터베이스의 값과 일치하는지 확인하는 기능이라고 배운다.

if (
  input.email === user.email &&
  input.password === user.password
) {
  return "Login successful";
}

두 값이 맞으면 로그인을 성공시키고, 다르면 실패시킨다.

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 문자열 두 개를 비교하는 것보다 더 많은 판단이 필요하다.

  • 이메일 주소를 알고 있다는 사실만으로 사용자를 식별할 수 있는가?
  • 비밀번호는 어떤 형태로 저장하고 비교해야 하는가?
  • 정지되거나 탈퇴한 계정도 로그인할 수 있는가?
  • 로그인 성공 후 사용자의 신원을 다음 요청까지 어떻게 유지하는가?
  • 공격자가 비밀번호를 계속 추측하면 어떻게 막는가?
  • 로그인 실패 응답이 계정 존재 여부를 노출하지는 않는가?
  • 비밀번호를 잊었거나 계정을 탈취당한 사용자는 어떻게 복구하는가?

로그인은 단순히 아이디와 비밀번호를 확인하는 기능이 아니다.

로그인은 사용자가 주장한 신원이 실제 본인의 것인지 여러 증거와 정책으로 확인하고, 그 결과를 이후 요청에서 안전하게 사용할 수 있는 인증 상태로 전환하는 과정이다.


이메일은 사용자를 찾는 값이지 본인임을 증명하는 값은 아니다

크리스가 팀 협업 서비스를 개발한다고 생각해 보자. 사용자는 이메일과 비밀번호를 입력해 로그인한다.

type LoginInput = {
  email: string;
  password: string;
};

이메일은 어떤 계정을 확인해야 하는지 알려준다. 그러나 이메일 주소를 입력할 수 있다는 사실은 해당 계정의 소유자임을 증명하지 않는다.

email
  ↓
어떤 계정을 확인할 것인가?

password
  ↓
그 계정을 사용할 수 있는 증거를 가지고 있는가?

이 둘은 서로 다른 책임을 가진다.

  • 이메일은 계정을 식별하는 데 사용된다.
  • 비밀번호는 사용자가 계정에 설정된 비밀을 알고 있는지 확인한다.
  • 일회용 인증 코드나 보안 키는 추가적인 증거가 될 수 있다.
  • 로그인 정책은 현재 상황에서 어느 정도의 증거가 필요한지 결정한다.

처음에는 다음처럼 이메일만으로 사용자를 찾을 수 있다.

const user =
  await userRepository.findByEmail(input.email);

하지만 찾은 사용자를 바로 로그인시키면 안 된다.

if (user) {
  return createSession(user.id);
}

이 코드는 이메일 주소만 알면 다른 사용자의 인증 상태를 만들 수 있다.

사용자를 찾는 단계와 신원을 증명하는 단계는 분리해야 한다.

const user =
  await userRepository.findByEmail(input.email);

const passwordMatches =
  user &&
  (await passwordHasher.verify(
    input.password,
    user.passwordHash
  ));

if (!passwordMatches) {
  throw new InvalidCredentialsError();
}

이 코드는 계정을 찾은 뒤 제출된 비밀번호가 저장된 인증 정보와 일치하는지 검증한다.

로그인의 첫 번째 설계 판단은 “어떤 사용자인가”와 “정말 그 사용자인가”를 구분하는 데서 시작한다.


비밀번호는 원문을 다시 읽기 위해 저장하는 데이터가 아니다

다음과 같이 비밀번호 원문을 저장하면 안 된다.

type User = {
  email: string;
  password: string;
};

데이터베이스가 노출되면 모든 사용자의 비밀번호가 그대로 공개된다. 같은 비밀번호를 다른 서비스에서도 사용한 사용자는 더 큰 피해를 볼 수 있다.

서비스에는 사용자의 원래 비밀번호를 다시 보여줄 필요가 없다. 로그인 시 같은 비밀번호가 제출되었는지만 확인하면 된다.

type UserCredential = {
  userId: string;
  passwordHash: string;
};

회원가입 시 비밀번호를 단방향 형태로 변환해 저장한다.

const passwordHash =
  await passwordHasher.hash(signupInput.password);

await credentialRepository.create({
  userId,
  passwordHash,
});

로그인할 때는 제출된 비밀번호를 원문과 비교하지 않고 저장된 해시를 검증한다.

const passwordMatches =
  await passwordHasher.verify(
    loginInput.password,
    credential.passwordHash
  );

여기서 passwordHasher는 비밀번호 저장에 적합한 알고리즘과 각 사용자별 salt, 비용 설정을 처리한다고 가정한다.

암호화와 해싱의 구체적인 차이는 별도의 주제로 다룰 수 있지만 로그인 설계에서 필요한 핵심은 분명하다.

  • 서비스가 비밀번호 원문을 저장하지 않는다.
  • 로그에 비밀번호를 남기지 않는다.
  • 오류 추적 도구에 요청 본문의 비밀번호가 전송되지 않게 한다.
  • 관리자도 사용자의 비밀번호를 조회할 수 없게 한다.
  • 비밀번호 변경 시 기존 인증 상태를 어떻게 처리할지 정한다.

비밀번호는 사용자를 설명하는 프로필 데이터가 아니다. 제한된 경로에서만 다뤄야 하는 인증 증거다.


로그인 입력은 데이터베이스를 조회하기 전에 검증해야 한다

로그인 폼에서 전달된 값은 외부 입력이다. 외부 입력은 검증 전까지 신뢰할 수 없다.

import { z } from "zod";

const LoginInputSchema = z.object({
  email: z
    .string()
    .trim()
    .email()
    .max(254)
    .transform((value) => value.toLowerCase()),
  password: z
    .string()
    .min(1)
    .max(256),
});

const input =
  LoginInputSchema.parse(request.body);

이 검증은 이메일 형식을 정리하고 지나치게 긴 입력을 제한한다.

로그인 단계에서는 회원가입과 같은 비밀번호 복잡도 규칙을 다시 적용하지 않는 편이 낫다. 과거 정책으로 생성된 정상 비밀번호가 현재 규칙과 다를 수 있기 때문이다. 로그인에서는 비밀번호가 전달되었는지와 시스템이 처리할 수 있는 길이인지 확인한 뒤 실제 인증 정보와 비교한다.

이메일의 대소문자 처리 방식도 서비스 전체에서 일관되어야 한다.

const normalizedEmail =
  normalizeEmail(input.email);

const user =
  await userRepository.findByEmail(normalizedEmail);

회원가입, 로그인, 비밀번호 재설정에서 서로 다른 정규화 규칙을 사용하면 같은 주소가 다른 계정처럼 취급될 수 있다.

검증은 사용자가 본인인지 증명하지 않는다. 인증 로직이 예측 가능한 입력을 받아 안전하게 동작하도록 요청 경계를 정리한다.


로그인 실패는 계정 존재 여부를 공개하지 않아야 한다

다음과 같이 실패 이유를 구체적으로 나누면 사용자에게 친절해 보일 수 있다.

if (!user) {
  throw new Error("This email is not registered.");
}

if (!passwordMatches) {
  throw new Error("The password is incorrect.");
}

하지만 공격자는 응답을 비교해 어떤 이메일이 가입되어 있는지 확인할 수 있다.

chris@example.com → 비밀번호가 틀림
unknown@example.com → 가입되지 않은 이메일

이 정보는 이후 비밀번호 추측, 피싱, 계정 탈취 시도에 사용될 수 있다.

외부 응답은 계정 존재 여부를 구분하지 않도록 설계할 수 있다.

if (!user || !passwordMatches) {
  throw new InvalidCredentialsError(
    "Email or password is incorrect."
  );
}

내부 로그에서는 운영에 필요한 실패 유형을 구분할 수 있지만 비밀번호나 민감한 인증 정보는 기록하지 않아야 한다.

logger.warn("Login failed", {
  reason: user ? "password_mismatch" : "user_not_found",
  requestId,
  ipHash,
});

내부 관찰 가능성과 외부 정보 노출은 서로 다른 책임이다.

응답 시간 차이도 계정 존재 여부를 드러낼 수 있다. 존재하지 않는 계정에서도 실제 비밀번호 검증과 비슷한 비용의 처리를 수행하는 방법을 고려할 수 있다.

const passwordHash =
  user?.passwordHash ?? DUMMY_PASSWORD_HASH;

const passwordMatches =
  await passwordHasher.verify(
    input.password,
    passwordHash
  );

이 코드는 사용자가 없더라도 일정한 형태의 비밀번호 검증을 수행한다.

완전히 동일한 응답 시간을 보장하기는 어렵지만, 계정 존재 여부에 따라 처리 과정이 지나치게 달라지지 않도록 할 수 있다.


인증 성공은 계정 상태까지 유효하다는 뜻이어야 한다

비밀번호가 맞아도 계정이 서비스를 사용할 수 없는 상태일 수 있다.

type AccountStatus =
  | "pending_verification"
  | "active"
  | "suspended"
  | "locked"
  | "deleted";

단순히 비밀번호만 확인하면 정지된 사용자도 로그인할 수 있다.

if (passwordMatches) {
  return sessionService.create(user.id);
}

계정 상태를 함께 확인해야 한다.

if (!passwordMatches) {
  throw new InvalidCredentialsError();
}

switch (user.status) {
  case "pending_verification":
    throw new EmailVerificationRequiredError();

  case "suspended":
  case "locked":
  case "deleted":
    throw new AccountUnavailableError();

  case "active":
    break;
}

그러나 상태마다 외부에 어느 정도의 정보를 보여줄지도 판단해야 한다.

이메일 인증이 필요한 사용자는 다음 행동을 안내할 수 있다. 반면 보안상 잠긴 계정이나 삭제 여부는 공격자에게 자세히 공개하지 않는 편이 나을 수 있다.

계정 상태의 Source of Truth는 서버의 사용자 데이터다. 프론트엔드에서 저장한 isActive나 이전 로그인 결과를 기준으로 현재 상태를 판단해서는 안 된다.

const user =
  await userRepository.findAuthenticationRecord(
    normalizedEmail
  );

로그인 시점에는 현재 상태와 인증 정보를 서버에서 다시 확인해야 한다.

로그인 성공은 “비밀번호가 맞다”만 의미하지 않는다. 현재 정책에 따라 해당 계정이 인증 상태를 만들 수 있다는 의미까지 포함해야 한다.


비밀번호 하나로 충분하지 않은 상황도 있다

비밀번호는 복사되거나 재사용되거나 피싱으로 노출될 수 있다. 서비스의 위험 수준에 따라 추가 인증 단계가 필요할 수 있다.

if (user.mfaEnabled) {
  return {
    status: "mfa_required",
    challengeId:
      await mfaService.createChallenge(user.id),
  };
}

비밀번호 검증은 끝났지만 로그인 전체는 아직 완료되지 않았다.

이메일과 비밀번호 확인
        ↓
추가 인증 필요 여부 판단
        ↓
일회용 코드 또는 보안 키 검증
        ↓
최종 인증 상태 생성

추가 인증 요청에는 짧은 생명주기와 제한된 권한을 가진 임시 상태가 필요하다.

type LoginChallenge = {
  id: string;
  userId: string;
  type: "totp" | "security_key" | "recovery_code";
  expiresAt: string;
  attemptsRemaining: number;
};

이 도전 상태는 일반 로그인 세션이 아니다. 추가 증거를 제출할 수 있는 제한된 중간 상태다.

일회용 코드도 외부 입력이므로 검증 전까지 신뢰할 수 없다.

const MfaInputSchema = z.object({
  challengeId: z.string().uuid(),
  code: z.string().regex(/^\d{6}$/),
});

코드 형식뿐 아니라 다음 사항도 확인해야 한다.

  • 도전이 만료되지 않았는가?
  • 해당 사용자에게 발급된 도전인가?
  • 이미 사용된 코드는 아닌가?
  • 시도 횟수를 초과하지 않았는가?
  • 인증 성공 후 도전을 폐기했는가?

추가 인증은 화면 하나를 더 보여주는 기능이 아니다. 서로 다른 인증 증거를 순서대로 확인하는 상태 전이 과정이다.


로그인 성공 후에는 제한된 인증 상태를 발급해야 한다

서버가 사용자의 신원을 확인했더라도 다음 요청은 별도의 HTTP 요청으로 들어온다.

POST /login
GET /projects
POST /projects/123/tasks

로그인 요청에서 비밀번호를 확인한 사실을 이후 요청과 연결할 방법이 필요하다.

const session =
  await sessionService.create({
    userId: user.id,
    authenticatedAt: new Date(),
  });

서버는 세션이나 토큰을 발급해 인증 결과를 다음 요청에서 확인할 수 있게 한다.

중요한 것은 인증 상태에 필요한 정보만 담는 것이다.

type Session = {
  id: string;
  userId: string;
  createdAt: string;
  expiresAt: string;
  lastSeenAt: string;
};

인증 상태에는 생명주기가 필요하다.

  • 언제 생성되었는가?
  • 언제 만료되는가?
  • 로그아웃하면 어떻게 폐기되는가?
  • 비밀번호가 변경되면 기존 상태를 유지할 것인가?
  • 계정이 정지되면 현재 세션을 어떻게 종료할 것인가?
  • 사용자가 모든 기기에서 로그아웃할 수 있는가?

로그인 성공 응답으로 사용자 전체 데이터를 그대로 저장하는 방식은 주의해야 한다.

localStorage.setItem(
  "user",
  JSON.stringify({
    ...user,
    passwordHash: user.passwordHash,
  })
);

비밀번호 해시와 내부 보안 정보는 클라이언트에 전달할 이유가 없다.

return {
  user: {
    id: user.id,
    displayName: user.displayName,
  },
  authentication: {
    expiresAt: session.expiresAt,
  },
};

클라이언트에는 화면에 필요한 최소 정보만 전달해야 한다.

세션과 토큰의 구체적인 구조는 다음 주제에서 더 자세히 다룰 수 있다. 로그인 관점에서 핵심은 인증 성공이 영구적인 신뢰를 만드는 것이 아니라 만료되고 폐기할 수 있는 제한된 인증 상태를 만든다는 점이다.


로그인했다고 모든 행동이 허용되는 것은 아니다

사용자가 로그인하면 서버는 “누구인지 확인된 사용자”라는 사실을 알 수 있다.

그러나 로그인했다는 사실만으로 모든 프로젝트를 수정할 권한이 생기지는 않는다.

if (!currentUser) {
  throw new UnauthenticatedError();
}

이 코드는 인증 여부만 확인한다.

프로젝트 삭제에는 별도의 권한 검사가 필요하다.

const membership =
  await membershipRepository.find({
    projectId,
    userId: currentUser.id,
  });

if (membership?.role !== "owner") {
  throw new ForbiddenError();
}

두 질문을 구분해야 한다.

인증(Authentication)
이 요청을 보낸 사용자는 누구인가?

권한(Authorization)
이 사용자가 이 데이터에 이 행동을 할 수 있는가?

로그인은 인증 과정이다. 로그인 이후의 각 요청은 해당 자원과 행동에 대한 권한을 별도로 확인해야 한다.

관리자 페이지를 프론트엔드에서 숨기는 것만으로는 권한 검사가 되지 않는다.

{currentUser.role === "admin" && (
  <AdminMenu />
)}

이 코드는 화면 표시를 제어할 뿐이다. 서버 API에서도 현재 사용자의 권한을 확인해야 한다.

await authorizationService.assertCan({
  actor: currentUser,
  action: "manage_users",
  resource: organization,
});

인증은 권한 판단에 필요한 사용자 신원을 제공하지만 허용되는 행동까지 자동으로 결정하지 않는다.


반복되는 로그인 시도는 정상적인 실패와 공격을 구분해야 한다

공격자는 하나의 계정에 많은 비밀번호를 시도하거나 여러 계정에 흔한 비밀번호를 시도할 수 있다.

for (const password of leakedPasswords) {
  await login({
    email: "chris@example.com",
    password,
  });
}

단순히 비밀번호가 맞는지만 검사하면 공격자는 제한 없이 추측할 수 있다.

로그인 경로에는 시도 제한이 필요하다.

const attemptKey =
  `login:${ipAddress}:${normalizedEmail}`;

const attempts =
  await rateLimiter.consume(attemptKey);

if (!attempts.allowed) {
  throw new TooManyLoginAttemptsError();
}

제한 기준은 한 가지 값에만 의존하지 않는 편이 낫다.

  • IP 주소만 사용하면 공유 네트워크의 정상 사용자까지 차단될 수 있다.
  • 이메일만 사용하면 공격자가 특정 사용자의 계정을 의도적으로 잠글 수 있다.
  • 기기 정보는 조작될 수 있다.
  • 고정된 제한은 분산 공격에 약할 수 있다.

따라서 IP, 계정, 시간 범위, 실패 패턴, 위험 신호를 조합해 단계적으로 대응할 수 있다.

일반적인 실패
  ↓
짧은 지연 또는 추가 기록
  ↓
반복 실패
  ↓
일시적 요청 제한
  ↓
높은 위험
  ↓
추가 인증·보안 알림·계정 보호

성공한 로그인 이후에도 실패 횟수를 무조건 삭제하기보다 보안 분석을 위한 기록을 남길 수 있다. 다만 로그에 비밀번호나 인증 코드를 저장해서는 안 된다.

로그인 실패는 사용자 경험의 일부이면서 보안 신호이기도 하다.


로그인 기록은 사용자가 자신의 인증 상태를 이해하게 해야 한다

사용자는 자신의 계정에 어떤 기기가 로그인되어 있는지 확인하고 싶을 수 있다.

type LoginEvent = {
  userId: string;
  occurredAt: string;
  result: "success" | "failure";
  ipHash: string;
  userAgentSummary: string;
  requestId: string;
};

로그인 이벤트는 다음 질문에 답하는 데 도움을 준다.

  • 언제 로그인이 성공했는가?
  • 평소와 다른 지역이나 기기에서 로그인했는가?
  • 짧은 시간에 실패가 반복되었는가?
  • 비밀번호 변경 후에도 이전 세션이 사용되고 있는가?
  • 사용자가 신고한 의심스러운 접근과 어떤 요청이 연결되는가?

그러나 보안 로그 자체에도 민감한 정보가 포함될 수 있다.

  • 원본 IP 주소 보관이 꼭 필요한가?
  • 로그는 얼마 동안 유지하는가?
  • 누가 로그인 기록을 조회할 수 있는가?
  • 이메일과 사용자 ID를 어느 수준으로 노출하는가?
  • 비밀번호와 인증 코드는 확실히 제외되는가?

사용자에게 보이는 기기 목록과 내부 보안 로그의 목적도 다르다.

기록목적보관 정보
사용자용 로그인 기기세션 확인과 로그아웃기기 이름, 최근 사용 시각
보안 이벤트공격 탐지와 조사결과, 위험 신호, 요청 ID
운영 로그장애 원인 추적오류 유형, 처리 단계
감사 기록중요한 계정 변경 추적수행자, 변경 내용, 시각

로그인은 성공과 실패를 반환하고 끝나는 요청이 아니다. 계정 보호와 사고 대응을 위해 추적 가능한 인증 사건이다.


로그아웃은 화면에서 사용자 정보를 지우는 일이 아니다

프론트엔드에서 사용자 상태를 초기화하면 화면상으로는 로그아웃된 것처럼 보인다.

authStore.setState({
  currentUser: null,
  isAuthenticated: false,
});

그러나 서버의 세션이나 재사용 가능한 인증 정보가 그대로 남아 있다면 다시 사용할 수 있다.

로그아웃은 현재 인증 상태를 더 이상 사용할 수 없게 만드는 과정이어야 한다.

await sessionService.revoke(currentSession.id);

클라이언트도 보유한 인증 정보를 제거한다.

authStore.clear();

로그아웃의 범위도 구분해야 한다.

type LogoutScope =
  | "current_session"
  | "other_sessions"
  | "all_sessions";

사용자는 현재 기기에서만 로그아웃하거나 계정 탈취가 의심될 때 모든 기기의 인증 상태를 종료할 수 있어야 한다.

비밀번호 변경, 이메일 변경, 추가 인증 해제와 같은 중요한 보안 변경 후 기존 세션을 유지할지도 정책으로 정해야 한다.

로그인은 인증 상태를 만드는 과정이고 로그아웃은 그 상태의 신뢰를 철회하는 과정이다. 화면 상태만 지우는 것으로는 충분하지 않다.


비밀번호 재설정은 로그인 우회 경로이므로 같은 수준으로 보호해야 한다

사용자가 비밀번호를 잊으면 이메일을 통해 재설정 링크를 받을 수 있다.

await passwordResetService.request({
  email: input.email,
});

비밀번호 재설정은 기존 비밀번호 없이 새로운 인증 정보를 설정하는 기능이다. 따라서 사실상 또 하나의 로그인 경로와 비슷한 위험을 가진다.

계정 존재 여부를 노출하지 않도록 응답을 통일할 수 있다.

return {
  message:
    "If an account exists, a reset link has been sent.",
};

재설정 토큰에는 제한된 목적과 생명주기가 필요하다.

type PasswordResetToken = {
  userId: string;
  tokenHash: string;
  expiresAt: string;
  usedAt: string | null;
};

재설정 토큰은 다음 조건을 가져야 한다.

  • 충분히 예측하기 어려워야 한다.
  • 데이터베이스에는 원문 대신 안전한 비교 형태로 저장한다.
  • 짧은 시간 뒤 만료된다.
  • 한 번 사용하면 다시 사용할 수 없다.
  • 새 토큰 발급 시 이전 토큰을 어떻게 처리할지 정한다.
  • 재설정 후 기존 세션을 종료할지 결정한다.
  • 요청과 사용 시도를 제한하고 기록한다.

계정 복구 흐름이 로그인보다 약하면 공격자는 로그인 화면 대신 복구 기능을 공격한다.

로그인 보안은 비밀번호 확인 코드만으로 완성되지 않는다. 가입, 인증, 로그인, 추가 인증, 로그아웃, 비밀번호 변경, 복구까지 연결된 전체 계정 생명주기로 설계해야 한다.


로그인 흐름은 여러 단계의 신뢰 판단으로 이어진다

팀 협업 서비스의 로그인 흐름을 정리하면 다음과 같다.

flowchart LR
    A[이메일·비밀번호 입력] --> B[입력 검증·정규화]
    B --> C[계정과 인증 정보 조회]
    C --> D[비밀번호 검증]
    D --> E[계정 상태 확인]
    E --> F{추가 인증 필요?}
    F -->|예| G[추가 인증 검증]
    F -->|아니오| H[인증 상태 발급]
    G --> H
    H --> I[보안 이벤트 기록]
    I --> J[이후 요청에서 인증 확인]
    J --> K[각 자원에 대한 권한 확인]

각 단계는 서로 다른 질문에 답한다.

  • 입력 검증은 서버가 처리할 수 있는 요청인지 확인한다.
  • 계정 조회는 어떤 사용자가 신원을 주장하는지 찾는다.
  • 비밀번호 검증은 첫 번째 인증 증거를 확인한다.
  • 계정 상태는 현재 로그인을 허용할 수 있는지 결정한다.
  • 추가 인증은 위험과 정책에 필요한 추가 증거를 확인한다.
  • 세션이나 토큰은 인증 결과를 이후 요청으로 전달한다.
  • 권한 검사는 인증된 사용자가 특정 행동을 할 수 있는지 판단한다.
  • 보안 기록은 실패, 공격, 계정 탈취에 대응할 근거를 남긴다.

로그인은 한 번의 if문이 아니라 신뢰할 수 없는 요청을 제한된 인증 상태로 바꾸는 여러 단계의 판단 과정이다.


로그인을 설계하기 전에 물어봐야 할 질문

사용자를 어떻게 식별하고 증명하는가

  1. 이메일이나 사용자 이름은 계정을 찾는 용도인가?
  2. 사용자가 본인임을 증명하는 인증 정보는 무엇인가?
  3. 비밀번호 외에 추가 인증이 필요한 상황은 무엇인가?
  4. 외부 로그인 제공자를 사용한다면 어떤 정보를 신뢰하는가?
  5. 인증 방법이 변경되어도 같은 사용자 ID를 유지할 수 있는가?

인증 정보를 안전하게 다루는가

  1. 비밀번호 원문을 저장하거나 기록하지 않는가?
  2. 비밀번호 저장에 적합한 해시 검증 방식을 사용하는가?
  3. 오류 추적 도구와 로그에서 비밀번호를 제거하는가?
  4. 인증 정보에 접근할 수 있는 코드와 운영자를 제한하는가?
  5. 비밀번호 변경 후 기존 인증 상태를 어떻게 처리하는가?

입력과 실패 응답을 검증했는가

  1. 이메일을 일관된 방식으로 정규화하는가?
  2. 지나치게 긴 입력을 제한하는가?
  3. 로그인에서 현재 회원가입용 복잡도 규칙을 잘못 적용하지 않는가?
  4. 외부 오류가 계정 존재 여부를 드러내지 않는가?
  5. 존재하는 계정과 없는 계정의 처리 차이가 지나치게 크지 않은가?

계정 상태를 확인하는가

  1. 이메일 인증 전 로그인을 허용하는가?
  2. 정지, 잠금, 탈퇴 상태에서 어떤 응답을 반환하는가?
  3. 계정 상태의 Source of Truth가 서버에 있는가?
  4. 상태 변경 시 기존 세션도 종료하는가?
  5. 복구 가능한 잠금과 영구적인 삭제를 구분하는가?

인증 상태의 생명주기가 있는가

  1. 로그인 성공 후 어떤 인증 상태를 발급하는가?
  2. 인증 상태는 언제 만료되는가?
  3. 로그아웃 시 서버에서도 폐기되는가?
  4. 사용자가 모든 기기의 인증 상태를 확인하고 종료할 수 있는가?
  5. 인증 정보가 탈취되었을 때 강제로 무효화할 수 있는가?

공격과 반복 실패에 대응하는가

  1. 계정과 IP 단위의 시도 제한이 있는가?
  2. 정상 사용자를 과도하게 차단하지 않는가?
  3. 분산된 비밀번호 추측을 탐지할 수 있는가?
  4. 의심스러운 로그인에 추가 인증을 요구할 수 있는가?
  5. 사용자에게 보안 알림을 보낼 기준이 있는가?

인증과 권한을 구분했는가

  1. 로그인하지 않은 요청과 권한이 없는 요청을 구분하는가?
  2. 로그인만으로 모든 자원에 접근하게 하지 않는가?
  3. 프론트엔드 표시와 서버 권한 검사를 구분하는가?
  4. 조직과 프로젝트 소속 관계를 서버에서 확인하는가?
  5. 권한이 변경되면 기존 인증 상태에 언제 반영되는가?

복구 흐름도 같은 수준으로 보호하는가

  1. 비밀번호 재설정 응답이 계정 존재를 노출하지 않는가?
  2. 재설정 토큰이 짧게 만료되고 한 번만 사용되는가?
  3. 토큰 원문을 안전하지 않은 형태로 저장하지 않는가?
  4. 재설정 요청과 사용 시도를 제한하는가?
  5. 복구 후 기존 세션과 보안 알림을 어떻게 처리하는가?

흔한 실수는 로그인을 비밀번호 비교 함수로만 보는 것이다

이메일만 찾으면 로그인시키는 방식

이메일은 계정을 식별하지만 해당 계정의 소유권을 증명하지 않는다.

식별과 인증 증거 검증을 분리해야 한다.

비밀번호 원문을 데이터베이스에 저장한다

데이터베이스가 노출되면 사용자의 비밀번호가 그대로 공개된다.

서비스에는 원문 복원이 아니라 안전한 비교가 필요하다.

실패 이유를 외부에 지나치게 자세히 보여준다

“존재하지 않는 이메일”과 “잘못된 비밀번호”를 구분하면 가입된 계정을 수집할 수 있다.

외부 응답과 내부 로그의 상세 수준을 분리해야 한다.

비밀번호가 맞으면 계정 상태를 확인하지 않는다

정지되거나 삭제된 계정이 새로운 인증 상태를 만들 수 있다.

인증 정보뿐 아니라 현재 계정 정책도 확인해야 한다.

로그인 성공을 영구적인 신뢰로 취급한다

인증 상태가 만료되거나 폐기되지 않으면 탈취 후 장기간 사용될 수 있다.

세션과 토큰에는 제한된 생명주기와 철회 방법이 필요하다.

로그인하면 모든 API를 사용할 수 있게 한다

인증은 사용자가 누구인지 확인할 뿐이다.

각 자원에 대한 소유권, 역할, 행동 권한은 별도로 검사해야 한다.

프론트엔드 상태만 지우고 로그아웃을 끝낸다

서버의 인증 상태가 남아 있으면 탈취된 정보가 계속 사용될 수 있다.

로그아웃은 서버에서 인증 상태의 신뢰를 철회해야 한다.

로그인만 보호하고 비밀번호 재설정은 약하게 만든다

공격자는 더 약한 복구 경로를 통해 계정을 탈취할 수 있다.

계정 복구도 인증과 같은 수준의 검증, 만료, 제한, 기록이 필요하다.


로그인의 핵심은 신원 증명을 제한된 인증 상태로 바꾸는 데 있다

프로그래밍을 처음 배울 때는 로그인을 아이디와 비밀번호가 일치하는지 확인하는 기능이라고 이해해도 충분하다.

const passwordMatches =
  await verifyPassword(input.password, user.passwordHash);

하지만 실제 서비스에서 로그인은 하나의 비교로 끝나지 않는다.

  • 이메일은 계정을 식별하는가, 신원을 증명하는가?
  • 비밀번호를 원문 없이 안전하게 검증하는가?
  • 입력을 일관되게 검증하고 정규화하는가?
  • 실패 응답이 계정 존재 여부를 노출하지 않는가?
  • 현재 계정 상태가 로그인을 허용하는가?
  • 필요한 경우 추가 인증을 요구하는가?
  • 로그인 성공 후 제한된 인증 상태를 발급하는가?
  • 반복되는 시도와 의심스러운 접근을 탐지하는가?
  • 로그인과 자원별 권한 검사를 구분하는가?
  • 로그아웃과 계정 복구까지 같은 인증 생명주기로 설계하는가?

로그인은 아이디와 비밀번호를 확인하는 기능이 아니다.

로그인은 사용자가 주장한 신원이 실제 본인의 것인지 여러 증거와 정책으로 확인하고, 그 결과를 이후 요청에서 안전하게 사용할 수 있는 인증 상태로 전환하는 과정이다.

profile
Vision eXperience Developer

0개의 댓글