본문 바로가기
IT/Serverless Stack

[AWS] 서버리스 스택-4

by 밤톨엽 2022. 4. 6.

해당 글은 [AWS] 서버리스 스택-3와 이어집니다.

이번 글에서는 사용자 인증 및 API 보안 등에 대해서 작성을 하였습니다.


서버리스 앱에서 인증

여태가지 작성한 서버리스 REST API를 만들었지만, 몇 가지 빠진 것이 있다.

  • 안전하지 않다.
  • 그리고 특정 사용자와 연결되어 있지 않다.

사용자가 메모 앱에 가입하고 인증된 사용자만 액세스 할 수 있도록 하는 방법이 추가로 필요하다.

AWS 서비스를 통해 인증 및 액세스 제어가 작동하는 방식을 살펴보도록 하자.

serverless-auth-api-architecture
serverless-auth-api-architecture

위에 그림은 사용자가 메모 앱에 등록하고 인프라를 보호할 수 있도록 다음과 같은 아키텍처로 변경한 모습이다.

Cognito 사용자 풀

사용자의 가입 및 로그인 기능을 관리하기 위해 Amazon Cognito User Pool 이라는 AWS 서비스를 사용한다.
해당 서비스를 통해 사용자의 로그인 정보를 저장하고, 또한 React 앱에서 사용자 세션을 관리합니다.

Cognito 자격 증명 풀

AWS 인프라에 대한 액세스 제어를 관리하기 위해 Amazon Cognito Identity Pools 이라는 다른 서비스를 사용한다. 이 서비스는 이전에 인증된 사용자가 연결하려는 리소스에 액세스 할 수 있는지 여부를 결정할 수 있다.

사실 자격 증명 풀이라는 것에는 Cognito 사용자 풀, Facebook, Google 등과 같은 다양한 인증 공급자가 있을 수 있다.
이 경우 자격 증명 풀은 사용자 풀에 연결돼서 사용된다.

인증 역할

Cognito 자격 증명 풀에는 규칙 집합(IAM 역할이라고 함)이 연결되어 있다. 인증된 사용자가 액세스 할 수 있는 리소스가 나열되며 이러한 리소스는 ARN이라는 ID를 사용하여 나열된다.

IAM 및 ARN에 대해 자세히 알고 싶다면 아래 링크를 참고한다.

인증 흐름

실제로 인증 과정이 어떻게 함께 작동하는지 살펴보자.

가입하기

  • 사용자는 새 사용자 풀 계정을 생성하여 메모 앱에 등록한다.
  • 사용자는 이메일과 비밀번호로 인증을 진행한다.
  • 이메일 인증을 위한 코드가 전송된다
  • 이것은 React 앱과 사용자 풀 사이에서 처리된다.

로그인

가입한 사용자는 이제 이메일과 비밀번호를 사용하여 로그인할 수 있습니다. React 앱은 이 정보를 사용자 풀로 보냅니다. 이것이 유효하면 React에서 세션이 생성됩니다.

인증된 API 요청

API에 연결하는 과정

  • React 클라이언트는 IAM Auth를 사용하여 보안이 설정된 API Gateway에 요청한다.
  • API Gateway는 사용자가 사용자 풀로 인증되었는지 여부를 자격 증명 풀로 확인한다.
  • 이 사용자가 이 API에 액세스 할 수 있는지 확인하기 위해 인증 역할을 사용한다.
  • 모든 것이 좋아 보이면 Lambda 함수가 호출되고 자격 증명 풀 사용자 ID를 전달한다.

S3 파일 업로드

  • React 클라이언트는 S3 버킷에 파일을 직접 업로드한다.
  • 자격 증명 풀을 확인하여 사용자 풀로 인증되었는지 확인한다.
  • 인증 역할에 S3 버킷에 파일을 업로드할 수 있는 액세스 권한이 있는 경우에만 업로드를 진행한다.

위에서 사용자 풀을 사용하는 대신 Facebook이나 Google을 사용할 수 있다.
혹은, 사용자 풀을 API Gateway에 직접 연결할 수도 있지만, 단점은 S3 버킷(또는 향후 다른 AWS 리소스)에 대한 액세스 제어를 중앙에서 관리하지 못할 수 있다는 단점이 존재한다.

서버리스 앱에 인증 추가

stacks/AuthStack.ts 경로에 파일을 생성하고 아래처럼 코드를 만들어주자.

import * as iam from 'aws-cdk-lib/aws-iam';
import * as sst from '@serverless-stack/resources';

export default class AuthStack extends sst.Stack {
  auth;

  constructor(scope: sst.App, id: string, props?: any) {
    super(scope, id, props);

    const { api, bucket } = props;

    // Cognito User Pool and Identity Pool 생성
    this.auth = new sst.Auth(this, 'auth-sykim', {
      cognito: {
        userPool: {
          // 유저는 이메일과 패스워드로 인증하도록 설정
          signInAliases: { email: true },
        },
      },
    });

    this.auth.attachPermissionsForAuthUsers([
      // API 접근 허용
      api,
      // 특정 s3 버킷의 폴더에 접근을 허용해준다.
      new iam.PolicyStatement({
        actions: ['s3:*'],
        effect: iam.Effect.ALLOW,
        resources: [
          bucket.bucketArn + '/private/${cognito-identity.amazonaws.com:sub}/*',
        ],
      }),
    ]);

    // 생성된 ID값들을 노출시켜준다
    this.addOutputs({
      Region: scope.region,
      UserPoolId: this.auth.cognitoUserPool!.userPoolId,
      IdentityPoolId: this.auth.cognitoCfnIdentityPool.ref,
      UserPoolClientId: this.auth.cognitoUserPoolClient!.userPoolClientId,
    });
  }
}

위에 선언한 코드에 대해서 간단히 알아보자.

  • 우리는 인증(Auth) 인프라를 위한 새로운 스택을 만든다. 반드시 여러 스택을 만들 필요는 없지만 여러 스택으로 나누어 작업하는 것도 가능하기 때문에 스택을 분리해서 구성해보았다.
  • Auth 구성은 Cognito 사용자 풀을 생성한다. 우리는 signInAliases 사용자가 이메일로 로그인하는 것을 명시하기 위해 속성(prop)을 사용하여 지정한다.
  • Auth 구성은 자격 증명 풀도 생성한다. Auth 에서 attachPermissionsForAuthUsers 기능을 사용하면 인증된 사용자가 액세스 할 수 있는 리소스를 지정할 수 있다.
  • 여기서 인증된 사용자들만 API에 액세스에 접근할 수 있도록 속성(prop)에 선언하여 넘겨준다.
  • 인증된 사용자들은 특정 S3 버킷에 액세스 할 수 있도록 설정한다. (이에 대해서는 아래에서 좀 더 자세히 살펴보자)
  • 마지막에서 생성된 인증 리소스의 ID를 출력한다.

S3 Bucket에 업로드된 파일에 대한 액세스 보안

// 특정 s3 버킷의 폴더에 접근을 허용해준다.
new iam.PolicyStatement({
  actions: ["s3:*"],
  effect: iam.Effect.ALLOW,
  resources: [
    bucket.bucketArn + "/private/${cognito-identity.amazonaws.com:sub}/*",
  ],
}),

위에 선언한 내용을 살펴보자.

  • 인증된 사용자는 private/${cognito-identity.amazonaws.com:sub}/* S3 버킷의 ARN 경로에 대한 액세스 권한을 부여한다.
  • cognito-identity.amazonaws.com:sub 는 인증된 사용자(사용자의 ID)를 뜻한다.
  • 따라서 사용자는 S3버킷 내의 폴더에만 액세스 할 수 있도록 설정한 것이다.
이를 통해 동일한 S3 버킷 내에서 사용자마다 파일 업로드에 대한 액세스를 분리한다.

어플리케이션에 추가

stacks/index.ts 파일을 아래처럼 수정하자.

// stacks/index.ts
import * as sst from "@serverless-stack/resources";
import StorageStack from "./StorageStack";
import ApiStack from "./ApiStack";
import AuthStack from "./AuthStack";

export default function main(app: sst.App): void {
  // 두번째 인자에 본인 이름이나 이니셜로 생성해주자
  // ex) storage-sykim
  const storageStack = new StorageStack(app, "storage-sykim");

  // 두번째 인자에 본인 이름이나 이니셜로 생성해주자
  // ex) api-sykim
  const apiStack = new ApiStack(app, "api-sykim", {
    table: storageStack.table,
  });

  // 두번째 인자에 본인 이름이나 이니셜로 생성해주자
  // ex) auth-sykim
  new AuthStack(app, "auth", {
    api: apiStack.api,
    bucket: storageStack.bucket,
  });
}

API에 인증 추가

API에 인증관련을 추가하기 위해 stacks/ApiStack.ts 파일을 아래처럼 수정하자.

// stacks/ApiStack.ts
import * as sst from '@serverless-stack/resources';
import { ApiAuthorizationType } from '@serverless-stack/resources';

export default class ApiStack extends sst.Stack {
  // 다른 스택에서 접근할 수 있도록 선언
  api;

  constructor(scope: sst.App, id: string, props?: any) {
    super(scope, id, props);

    const { table } = props;

    // API 생성 (두번째 인자에 본인 이니셜을 포함해 ID를 넣어주자)
    this.api = new sst.Api(this, 'api-sykim', {
      // AWS_IAM 인증하도록 설정
      defaultAuthorizationType: ApiAuthorizationType.AWS_IAM,
      defaultFunctionProps: {
        environment: {
          TABLE_NAME: table.tableName,
        },
      },
      routes: {
        'POST /notes': 'src/create.main', // 메모 생성 API
        'GET /notes/{id}': 'src/get.main', // 메모 조회 API
        'GET /notes': 'src/list.main', // 메모 목록 조회 API
        'PUT /notes/{id}': 'src/update.main', // 메모 수정 API
        'DELETE /notes/{id}': 'src/delete.main', // 메모 삭제 API
      },
    });

    // API가 DynamoDB 테이블에 접근할 수 있도록 권한 설정
    this.api.attachPermissions([table]);

    // API의 EndPoint Url을 노출
    this.addOutputs({
      ApiEndpoint: this.api.url,
    });
  }
}

이제 npx sst start 명령어로 앱을 배포하여 인증관련 ID를 확인한다.
(아래처럼 배포 완료 후 각 ID를 확인할 수 있다.)

Stack dev-notes-auth
  Status: deployed
  Outputs:
    Region: us-east-1
    IdentityPoolId: us-east-1:9bd0357e-2ac1-418d-a609-bc5e7bc064e3
    UserPoolClientId: 3fetogamdv9aqa0393adsd7viv
    UserPoolId: us-east-1_TYEz7XP7P
IdentityPoolId
UserPoolClientId
UserPoolId
아래에서 인증을 테스트하기위해 각 3가지 Id값을 반드시 기록해놓자

Cognito ID

이전에 구성한 lambda에서 메모 관련 비지니스 로직을 구성할 때 userId 의 값을 "123"으로 하드코딩으로 구성했기 때문에 실제 Cognito ID를 사용할 수 있도록 변경해야 한다.

src/create.ts, get.ts, update.ts, delete.ts 코드에서 userId의 값을 아래처럼 변경한다.

userId: event.requestContext.authorizer.iam.cognitoIdentity.identityId

src/list.ts 코드에서 ExpressionAttirbuteValues 부분을 아래처럼 변경한다.

ExpressionAttributeValues: {
  ':userId': event.requestContext.authorizer.iam.cognitoIdentity.identityId,
}

구성중에 handler 함수쪽에 event를 받을 수 있도록 인자가 없을경우 아래처럼 추가해야 한다.

export const main = handler(async (event) => {

API 테스트

AWS CLI를 사용하여 이메일과 암호로 사용자를 등록해보자.
(터미널에서 진행한다.)

아래 명령어를 터미널에서 입력하여 실행시키자
(맥OS, 윈도우OS에 따라 선택해서 명령어를 실행)

## 맥OS용
aws cognito-idp sign-up \
  --region ap-northeast-2 \
  --client-id USER_POOL_CLIENT_ID \
  --username admin@example.com \
  --password Passw0rd!
  
## 윈도우OS용(한줄로 해야한다.)
aws cognito-idp sign-up --region ap-northeast-2 --client-id USER_POOL_CLIENT_ID --username admin@example.com --password Passw0rd!
region의 경우 ap-northeast-2 나머지 값은 바로 이전에 생성된 ID값을 사용한다.
IdentityPoolId
UserPoolClientId
UserPoolId 

아래 명령어를 터미널에서 입력하여 실행시키자
(맥OS, 윈도우OS에 따라 선택해서 명령어를 실행)

## 맥OS용
aws cognito-idp admin-confirm-sign-up \
  --region ap-northeast-2 \
  --user-pool-id USER_POOL_ID \
  --username admin@example.com

## 윈도우OS용
aws cognito-idp admin-confirm-sign-up --region ap-northeast-2 --user-pool-id USER_POOL_ID --username admin@example.com

인증으로 API 테스트

API 엔드포인트에 안전하게 도달하려면 다음 단계를 따라야한다.

  1. 사용자 풀에 대해 인증하고 사용자 토큰을 획득한다.
  2. 사용자 토큰을 사용하여 자격 증명 풀에서 임시 IAM 자격 증명을 가져온다.
  3. IAM 자격 증명을 사용하여 서명 버전 4 로 API 요청을 통과한다.
serverless framework에서 cli로 명령어로 인증된 API를 테스트할 수 있게 지원한다.

명령어를 실행할 때 아래 값들은 이전에 생성됬던 값들로 교체해야 한다.

  • USER_POOL_ID
  • USER_POOL_CLIENT_ID
  • IDENTITY_POOL_ID
  • API_ENDPOINT

npx sst start 명령어로 API가 실행가능한 상태에서 터미널에서 아래 명령어로 테스트를 진행한다.

맥용 명령어

npx aws-api-gateway-cli-test \
--username='admin@example.com' \
--password='Passw0rd!' \
--user-pool-id='USER_POOL_ID' \
--app-client-id='USER_POOL_CLIENT_ID' \
--cognito-region='ap-northeast-2' \
--identity-pool-id='IDENTITY_POOL_ID' \
--invoke-url='API_ENDPOINT' \
--api-gateway-region='ap-northeast-2' \
--path-template='/notes' \
--method='POST' \
--body='{"content":"hello world","attachment":"hello.jpg"}'

윈도우용 명령어

npx aws-api-gateway-cli-test --username=admin@example.com --password=Passw0rd! --user-pool-id=USER_POOL_ID로교체(따옴표없이) --app-client-id=USER_POOL_CLIENT_ID로교체(따옴표없이) --cognito-region=ap-northeast-2 --identity-pool-id=IDENTITY_POOL_ID로교체(따옴표없이) --invoke-url=API_ENDPOINT로교체(따옴표없이) --api-gateway-region=ap-northeast-2 --path-template=/notes --method=POST --body='{\"content\":\"helloworld\",\"attachment\":\"hello.jpg\"}'

이전에 Cognito를 생성할때 기입해둔 ID와 npx sst start 명령어 실행시 나오는 전체 api endpoint url 값을 적절히 교체해서 테스트를 진행해야 한다.

API_ENDPOINT는 이전에 API를 테스트할 때 생성되는 전체 URL이다.
예) https://5bv7x0iuga.execute-api.us-east-1.amazonaws.com 

명령어가 성공하면 아래처럼 유사하게 응답이 나온다.

Authenticating with User Pool
Getting temporary credentials
Making API request
{
  status: 200,
  statusText: 'OK',
  data: {
    userId: 'us-east-1:edc3b241-70c3-4665-a775-1f2df6ddfc26',
    noteId: '6f9f41a0-18b4-11eb-a94f-db173bada851',
    content: 'hello world',
    attachment: 'hello.jpg',
    createdAt: 1603844881083
  }
}

서버리스 API에서 CORS 처리

지금까지 과정을 통해 사용자가 사용할 수 있는 메모와 파일을 업로드할 수 있는 S3 버킷을 생성할 수 있는 서버리스 API 백엔드를 구축했다. 다음장부터는 프론트엔드 React 앱 관련 구성을 시작한다.

하지만, 마지막으로 처리해야 할 문제가 한가지 있다.
CORS(Cross-Origin Resource Sharing) 설정을 해야한다.

React 앱은 브라우저 내에서 실행될 예정이므로(서버리스 API 및 S3 버킷과 별도의 도메인에서 호스팅 예정) 리소스에 연결할 수 있도록 CORS를 구성해야 한다.

CORS 이해하기

서버리스 API에서 CORS를 지원하려면 두 가지 작업을 수행해야 한다.

  1. 실행 전 OPTIONS 요청
    특정 유형의 교차 도메인 요청(PUT, DELETE, 인증 헤더가 있는 요청 등)의 경우 브라우저는 먼저 요청 방법 OPTIONS를 사용하여 실행 전 요청을 만든다. 이러한 API는 이 API에 액세스할 수 있는 도메인과 허용되는 HTTP 메서드로 응답해야 한다.
  2. CORS 헤더로 응답
    다른 모든 유형의 요청에 대해 적절한 CORS 헤더를 포함해야 한다. 위의 헤더와 마찬가지로 이러한 헤더에는 허용되는 도메인이 포함되어야 한다.

만약 CORS설정을 제대로 하지 않았다면 HTTP 응답이 아래와 같을 수 있다.

No 'Access-Control-Allow-Origin' header is present on the requested resource

API Gateway CORS

stacks/ApiStack.ts 경로에 있는 파일에서 API를 생성하는 부분을 아래처럼 수정해 API Gateway에 CORS를 적용하자

// API 생성 (두번째 인자에 본인 이니셜을 포함해 ID를 넣어주자)
this.api = new sst.Api(this, 'api-sykim', {
  // AWS_IAM 인증하도록 설정
  defaultAuthorizationType: ApiAuthorizationType.AWS_IAM,
  defaultFunctionProps: {
    environment: {
      TABLE_NAME: table.tableName,
    },
  },
  cors: true, // --> CORS 설정 추가
  routes: {
    'POST /notes': 'src/create.main', // 메모 생성 API
    'GET /notes/{id}': 'src/get.main', // 메모 조회 API
    'GET /notes': 'src/list.main', // 메모 목록 조회 API
    'PUT /notes/{id}': 'src/update.main', // 메모 수정 API
    'DELETE /notes/{id}': 'src/delete.main', // 메모 삭제 API
  },
});

Lambda 함수의 CORS 헤더 구성

src/util/handler.ts 경로에 있는 파일에서 return 문을 아래처럼 수정하자.

return {
  statusCode,
  body: JSON.stringify(body),
  headers: {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Credentials": true,
  },
};

S3에서 CORS 처리

stacks/StorageStack.ts 경로에 있는 파일에서 bucket을 생성하는 부분을 아래처럼 수정하자.

import { HttpMethods } from 'aws-cdk-lib/aws-s3'; // 맨위에 import 추가

// S3Bucket 생성
this.bucket = new sst.Bucket(this, "Uploads", {
  s3Bucket: {
    // 클라이언트에서 S3 버킷으로 접근가능하도록 CORS 설정
    cors: [
      {
        maxAge: 3000,
        allowedOrigins: ["*"],
        allowedHeaders: ["*"],
        allowedMethods: [
          HttpMethods.GET,
          HttpMethods.POST,
          HttpMethods.PUT,
          HttpMethods.DELETE,
          HttpMethods.HEAD,
        ],
      },
    ],
  },
});
API 인증구성을 완료하며 Github에 소스를 올려주자

서버리스 스택-4 정리

  • AWS Cognito 리소스 생성
  • API에 인증관련 구성
  • API관련 인증 테스트
  • CORS 구성

이번 문서에서는 위와 같이 API에 대한 인증을 구성해보았습니다.

다음 파트부터는 리액트 프론트엔드 구성 대해 다루게 되며 [React] 서버리스 메모앱-1 파트에서 정리하도록 하겠습니다.

'IT > Serverless Stack' 카테고리의 다른 글

[AWS] 서버리스 스택-3  (0) 2022.03.24
[AWS] 서버리스 스택-2  (0) 2022.03.22
[AWS] 서버리스 스택-1  (0) 2022.03.21