Amazon Web Services 한국 블로그

Amazon Cognito 기반 암호가 불필요한 서버리스 이메일 인증 구현 방법

대체로 웹 사이트에서 암호를 기억하고 있기란 쉽지 않습니다. 특히 자주 사용하지 않는 암호라면 더욱 그렇지요. 대부분의 사람처럼, 여러분도 웹 사이트와 앱에서 “암호를 잊어버리셨습니까?” 링크나 버튼을 클릭하는 일이 꽤 익숙할지도 모릅니다.

그래서 많은 사람이 짧은 암호를 사용하거나 떠올리기 쉬운 암호를 사용하거나 여러 사이트와 앱에서 같은 암호를 재사용하는 등 잘못된 방법을 사용하는 경향이 있습니다. 암호 관리자처럼, 이를 위한 솔루션도 있지만, 실제로 암호에 기반한 보안은 안전하지 않으며, 특히 사용자에게 친숙하지 않습니다.

지문이나 얼굴 인식을 사용하는 등 암호 로그인을 대체하는 방법도 있긴 합니다. 하지만 항상 이러한 방법이 사용 가능한 것은 아닙니다.

손쉽게 인증 서비스를 구현하게 해 주는 Amazon Cognito에서는 대안을 제공합니다. 로그인할 때 예를 들어, 이메일, SMS 또는 푸시 알림을 통해 웹 사이트나 앱에서 임시 일회성 로그인 코드를 사용자에게 보내기만 하고, 암호를 입력하지 않아도 된다면 어떨까요? 그냥 코드를 받아서 입력하고 로그인하면 됩니다. “암호를 잊어버리셨습니까” 프로세스와 비슷하지만, 더 간단하고 짧습니다. 또한 암호를 잊을까 걱정하지 않아도 됩니다.

Amazon Cognito 사용자 풀을 사용하면 사용자 지정 인증 플로우를 생성할 수 있습니다. 이 블로그 게시물에서는 사용자 이메일 주소로 일회성 로그인 코드를 전송하는 암호 없는 인증 플로우의 샘플을 구현하여 이를 수행하는 방법을 보여줍니다.

솔루션 개요

암호 없는 이메일 인증 솔루션에서는 Amazon Cognito 사용자 풀과 한두 가지 Lambda 함수를 사용합니다. 이를 함께 사용하여 사용자 지정 인증 플로우를 구현합니다. 일회성 로그인 코드를 포함하는 이메일을 전송하기 위해서는 Amazon Simple Email Service(Amazon SES)를 사용합니다. 또한 로그인 프로세스는 사용자 지정 UI 페이지(HTML 및 JavaScript)로 지원됩니다.

솔루션 개요 - 다이어그램

이 다이어그램과 다음 단계에서 솔루션의 프로세스를 설명합니다.

  1. 사용자가 사용자 지정 로그인 페이지에 이메일 주소를 입력합니다. 그러면 이 주소를 Amazon Cognito 사용자 풀로 전송합니다.
  2. 사용자 풀에서 “인증 문제 정의” Lambda 함수를 호출합니다. 이 Lambda 함수는 생성해야 하는 사용자 지정 인증 문제를 확인합니다.
  3. 사용자 풀에서 “인증 문제 생성” Lambda 함수를 호출합니다. 이 Lambda 함수는 보안 로그인 코드를 생성하고 Amazon SES를 사용하여 사용자에게 메일로 전송합니다.
  4. 사용자가 메일박스에서 보안 로그인 코드를 검색하고 사용자 지정 로그인 페이지에 이 코드를 입력합니다. 그러면 이 페이지에서 해당 코드를 사용자 풀로 전송합니다.
  5. 사용자 풀에서 “인증 문제 응답 확인” Lambda 함수를 호출합니다. 이 Lambda 함수는 사용자가 입력한 코드를 확인합니다.
  6. 사용자 풀에서 “인증 문제 정의” Lambda 함수를 호출합니다. 이 Lambda 함수는 인증 문제에 성공적으로 응답되었는지, 추가 인증 문제는 필요하지 않은지 확인합니다. 사용자 풀에 대한 응답에 “issueTokens: true”가 포함됩니다. 이제 사용자 풀은 사용자가 인증되었다고 간주하고, 사용자에게 올바른 JWT(JSON 웹 토큰)을 보냅니다(응답에서는 4).

서버리스 애플리케이션

샘플 구현은 서버리스 애플리케이션으로 패키지되었습니다. AWS Serverless Application Repository에서 배포하고 작동 방식을 확인하세요.

사용자 풀 구성에서 관련된 부분은 다음과 같습니다.

  • 사용자 이름으로 이메일 주소가 필요합니다.
  • “사용자 지정 인증만 허용”으로 구성된 앱 클라이언트가 있습니다. 사용자가 가입할 때 Amazon Cognito에 암호가 필요하기 때문입니다. 이에 대해 저희는 임의 문자열을 제공합니다. 실제로 사용자가 나중에 이 암호로 로그인하는 일이 없도록 하기 위해서입니다.
  • 사용자 지정 인증 플로우를 구현하는 다음 Lambda 함수(Node.js 8.10)가 구성됩니다.
    • 인증 문제 정의 – 이 Lambda 함수는 사용자 지정 인증 플로우를 추적합니다. 이는 상태 시스템의 결정자 함수와 비슷합니다. 이 함수는 사용자에게 어떤 순서로 어떤 인증 문제를 표시하는지 결정합니다. 마지막에 사용자가 인증에 성공하거나 실패한 경우 사용자 풀에 다시 보고합니다. 이 Lambda 함수는 사용자 지정 인증 플로우가 시작될 때, 그리고 “인증 문제 응답 확인” 트리거가 끌날 때마다 호출됩니다.
    • 인증 문제 생성 – 이 Lambda 함수는 사용자에 대해 고유한 인증 문제를 생성하기 위해 “인증 문제 정의” 트리거에 기반하여 호출됩니다. 이를 사용하여 일회성 로그인 코드를 생성하고 사용자에게 메일로 전송합니다.
    • 인증 문제 응답 확인 – 이 Lambda 함수는 사용자가 인증 문제에 대한 답변을 제공할 때 사용자 풀에서 호출됩니다. 여기서는 응답이 올바른지만 확인합니다.
  • 사용자는 직접 본인을 가입시킬 수 있으며, 사전 가입 사용자 풀 트리거를 사용하여 자동 확인됩니다. 로그인 기능의 핵심은 이메일 주소이기 때문에, 사용자가 별도로 자신의 이메일 주소를 확인하지 않아도 됩니다.

인증 문제 생성 트리거

코드를 검토하면, 보안 로그인 코드를 생성하고 이를 사용자에게 메일로 전송하는 과정을 확인할 수 있습니다. 사용자에게는 올바른 코드를 입력하는 3번의 기회가 주어지며, 모두 실패하면 새 로그인 코드를 받아야 합니다.

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';
import { randomDigits } from 'crypto-secure-random-digit';
import { SES } from 'aws-sdk';

const ses = new SES();

export const handler: CognitoUserPoolTriggerHandler = async event => {

    let secretLoginCode: string;
    if (!event.request.session || !event.request.session.length) {

        // This is a new auth session
        // Generate a new secret login code and mail it to the user
        secretLoginCode = randomDigits(6).join('');
        await sendEmail(event.request.userAttributes.email, secretLoginCode);

    } else {

        // There's an existing session. Don't generate new digits but
        // re-use the code from the current session. This allows the user to
        // make a mistake when keying in the code and to then retry, rather
        // the needing to e-mail the user an all new code again.    
        const previousChallenge = event.request.session.slice(-1)[0];
        secretLoginCode = previousChallenge.challengeMetadata!.match(/CODE-(\d*)/)![1];
    }

    // This is sent back to the client app
    event.response.publicChallengeParameters = {
        email: event.request.userAttributes.email
    };

    // Add the secret login code to the private challenge parameters
    // so it can be verified by the "Verify Auth Challenge Response" trigger
    event.response.privateChallengeParameters = { secretLoginCode };

    // Add the secret login code to the session so it is available
    // in a next invocation of the "Create Auth Challenge" trigger
    event.response.challengeMetadata = `CODE-${secretLoginCode}`;

    return event;
};

async function sendEmail(emailAddress: string, secretLoginCode: string) {
    const params: SES.SendEmailRequest = {
        Destination: { ToAddresses: [emailAddress] },
        Message: {
            Body: {
                Html: {
                    Charset: 'UTF-8',
                    Data: `<html><body><p>This is your secret login code:</p>
                           <h3>${secretLoginCode}</h3></body></html>`
                },
                Text: {
                    Charset: 'UTF-8',
                    Data: `Your secret login code: ${secretLoginCode}`
                }
            },
            Subject: {
                Charset: 'UTF-8',
                Data: 'Your secret login code'
            }
        },
        Source: process.env.SES_FROM_ADDRESS!
    };
    await ses.sendEmail(params).promise();
}

인증 문제 확인 트리거

이 함수에서는 사용자 응답이 secretLoginCode와 일치하는지만 확인합니다.

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';

export const handler: CognitoUserPoolTriggerHandler = async event => {
    const expectedAnswer = event.request.privateChallengeParameters!.secretLoginCode; 
    if (event.request.challengeAnswer === expectedAnswer) {
        event.response.answerCorrect = true;
    } else {
        event.response.answerCorrect = false;
    }
    return event;
};

인증 문제 정의 트리거

이 함수는 인증 플로우를 관리하는 결정자 함수입니다. 이 Lambda 함수에 제공된 세션 배열(event.request.session)에는 인증 플로우의 전체 상태가 나와 있습니다.

빈 경우 사용자 지정 인증 플로우가 막 시작된 것입니다. 항목이 있으면 사용자 지정 인증 플로우가 진행 중임을 나타냅니다. 사용자에게 인증 문제가 제시되고, 사용자가 답변을 제공하며, 답변이 올바른지, 잘못되었는지 확인합니다. 어느 경우든, 결정자 함수는 다음에 수행할 작업을 결정해야 합니다.

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';

export const handler: CognitoUserPoolTriggerHandler = async event => {
    if (event.request.session &&
        event.request.session.length >= 3 &&
        event.request.session.slice(-1)[0].challengeResult === false) {
        // The user provided a wrong answer 3 times; fail auth
        event.response.issueTokens = false;
        event.response.failAuthentication = true;
    } else if (event.request.session &&
        event.request.session.length &&
        event.request.session.slice(-1)[0].challengeResult === true) {
        // The user provided the right answer; succeed auth
        event.response.issueTokens = true;
        event.response.failAuthentication = false;
    } else {
        // The user did not provide a correct answer yet; present challenge
        event.response.issueTokens = false;
        event.response.failAuthentication = false;
        event.response.challengeName = 'CUSTOM_CHALLENGE';
    }

    return event;
};

사전 가입 트리거

이 함수는 가입 중 사용자와 이메일 주소를 자동 확인합니다.

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';

export const handler: CognitoUserPoolTriggerHandler = async event => {
    event.response.autoConfirmUser = true;
    event.response.autoVerifyEmail = true;
    return event;
};

사용자 지정 로그인 페이지 구현

사용자 풀의 사용자 지정 인증 플로우를 조정하려면 사용자 지정 로그인 페이지가 필요합니다. AWS Amplify 프레임워크를 사용하여 Amazon Cognito와 사용자 지정 로그인 페이지를 통합할 수 있습니다.

자주 사용하는 프레임워크(React, Angular, Vue, plain HTML/JavaScript 등)에서 사용자 지정 로그인 페이지를 구현할 수 있습니다. 다음은 샘플 솔루션의 몇 가지 JavaScript(TypeScript) 예제입니다.

다음에서는 JavaScript로 AWS Amplify 프레임워크를 시작하는 방법을 보여줍니다.

import Amplify from 'aws-amplify';

Amplify.configure({
  Auth: {
    region: 'your region',
    userPoolId: 'your userPoolId',
    userPoolWebClientId: 'your clientId',
  }
});

가입

사용자가 본인을 직접 가입시킬 수 있으려면 AWS에서 사용자의 암호를 “생성”해야 합니다. 사용자가 가입할 때 Amazon Cognito에서 암호를 요구하기 때문입니다.

import { Auth } from 'aws-amplify';
export async function signUp(email: string, fullName: string) {
  const params = {
    username: email,
    password: getRandomString(30),
    attributes: {
      name: fullName
    }
  };
  await Auth.signUp(params);
}

function getRandomString(bytes: number) {
  const randomValues = new Uint8Array(bytes);
  window.crypto.getRandomValues(randomValues);
  return Array.from(randomValues).map(intToHex).join('');
}

function intToHex(nr: number) {
  return nr.toString(16).padStart(2, '0');
}

로그인

인증을 시작하고 사용자 지정 플로우를 시작합니다.

import { CognitoUser } from 'amazon-cognito-identity-js';

let cognitoUser: CognitoUser; // Track authentication flow state in this object
export async function signIn(email: string) {
    cognitoUser = await Auth.signIn(email);
}

사용자 지정 인증 문제에 답변 제공

사용자는 메일을 확인하고 보안 로그인 코드를 검색해야 합니다. 사용자가 보안 로그인 코드를 입력하면 AWS Amplify를 호출하여 사용자 풀에 사용자 지정 인증 문제의 답변으로 보안 로그인 코드를 전송합니다. 그런 다음, 다음 세 가지 상황 중 하나가 진행될 수 있습니다.

  • 사용자가 올바른 코드를 입력했습니다. 사용자 풀에서 JWT를 사용하여 AWS Amplify에 응답하고, AWS Amplify는 이 코드를 사용자 브라우저(기본적으로 로컬 스토리지)에 저장합니다.
  • 사용자가 올바른 코드를 입력하지 않았지만, 아직 3번째 시도가 아니므로 올바른 코드를 입력할 기회가 아직 남았습니다.
  • 사용자가 올바른 코드를 입력하지 않았고 벌써 3번째 시도이므로 인증이 실패합니다. 사용자는 로그인 페이지로 돌아가 새 사용자 지정 인증 플로우를 시작해야 합니다.
export async function answerCustomChallenge(answer: string) {

    // Send the answer to the User Pool
    // This will throw an error if it’s the 3rd wrong answer
    cognitoUser = await Auth.sendCustomChallengeAnswer(cognitoUser, answer);

    // It we get here, the answer was sent successfully,
    // but it might have been wrong (1st or 2nd time)
    // So we should test if the user is authenticated now
    try {
        // This will throw an error if the user is not yet authenticated:
        await Auth.currentSession();
    } catch {
        console.log('Apparently the user did not enter the right code');
    }

}

제한 시간 3분

사용자 지정 인증 문제가 제공된 후 이메일에서 실제로 보안 로그인 코드를 검색하고 답변으로 이 코드를 제공하는 데 3분의 시간이 사용자에게 주어집니다. 이 시간이 지나면 사용자 지정 인증 플로우 제한 시간이 초과되고 사용자는 새 사용자 지정 인증 플로우를 시작하여 새 보안 로그인 코드를 받아야 합니다. 이 3분의 제한 시간은 Amazon Cognito에 의해 서버 측에서 시행됩니다. Multi-Factor Authentication(MFA)의 코드 입력 제한 시간과 동일합니다.

요약

Amazon Cognito 사용자 지정 인증 플로우를 사용하여 이메일로 받은 보안 로그인 코드를 통해 암호 없는 인증을 구현했습니다. 웹 사이트 또는 앱의 보안 요구 사항에 따라, 이 솔루션을 사용하여 보안과 사용자 편이성 사이에서 적절한 균형을 맞출 수 있습니다. 이 솔루션은 Lambda 함수에서 구축되었기 때문에 필요에 따라 확장하고 조정할 수 있습니다. 개발자 안내서에서 사용자 지정 인증에 대해 자세히 알아보고 코딩해 보십시오!

언제나 그렇듯이 AWS는 여러분의 의견에 귀를 기울입니다. 아래에 댓글을 달거나 AWS Cognito 포럼에서 의견을 게재하실 수 있습니다.

리소스

  • GitHub에서 코드를 보고 샘플 솔루션을 구축하는 방법에 대해 알아보십시오. 직접 코드를 배포하고 실행할 수 있습니다.
  • AWS Serverless Application Repository에서 제공하는 샘플 솔루션에서 Amazon Cognito 리소스를 배포할 수 있습니다.

– Ott Kruse;

Otto Kruse는 네덜란드 기반 전문 서비스 컨설턴트이며, 앱 개발 사례를 중점적으로 다룹니다.