Cognitoユーザー認証によるS3 のAccess制御をAmplifyとReactとDropzoneを使って実装する。(その4)

Cognitoユーザー認証によるS3 のAccess制御をAmplifyとReactとDropzoneを使って実装する。(その3)」の続きです。

npm start
します。Browseが起動して、Login画面が表示されます。画面中付近に、 Upload Fileを入力できるボタンが表示されます。

S3のBucketが空であることを確認しておきます。Management ConsoleからS3を開き、upload Bucketをクリックします。

Web Applicationに戻って、FileをUploadします。 「Browse」をクリックして、Fileを指定します。今回はTiff Fileのみ選択できるようにしてあるので、適当なTiff Fileを指定して、「開く」をクリックします。

指定したFileが表示されていることを確認して、「Upload File」をクリックします。Uploadできず、Errorが表示されます。Loginしていない状態では、FileのUploadが出来ないことを確認しました。

再度、S3のUpload Bucketが空であることを確認しておきます。

Loginして、同様にFileをUploadします。Fileは指定されたままなので、「Upload File」をクリックします。もちろん「Browse」をクリックして、Fileを再指定しても良いです。FileがUploadできました。

S3にFileがUploadされているか確認します。Management ConsoleからS3を開き、Upload Bucketをクリックします。Bucketの内容を表示している画面のままでしたら、更新のIconをクリックします。「protected」というFolderが作成されています。「protected」をクリックします。「ap-northeast-1:」で始まるFolderが作成されています。そのFolderをクリックします。「upload」というFolderが作成されています。「upload」をクリックします。UploadしたFileが見えます。今回は、AmplifyのStorageのLevelにProtectedを指定したので、上記のような階層にFileがUploadされます。Policyを使ってS3へのAccess制御を指定しましたが、Amplify StorageのLevelとPolicyを細かく指定することによって、User間でお互いにFileを見れるようにしたり、他のUserからは見れないようにしたりすることが可能です。

この方法ではUploadするFileを一つ一つ指定してUploadする必要があります。それでは不便なため、複数のFileを一気にUploadできるようにするためにDropZoneを使ってUploadするFileを指定できるようにします。まず、react-dropzoneをInstallします。
cd upload
npm install -save react-dropzone

App.jsを編集してDropZoneからUpload Fileを指定するように変更します。

import React, { useState, useCallback, useMemo }  from 'react';
import { useDropzone } from 'react-dropzone';
import Storage from "@aws-amplify/storage";
import { SetS3Config } from "./services";
import logo from './logo.svg';
import './App.css';
const baseStyle = {
  flex: 1,
  display: 'flex',
  flexDirection: 'column',
  alignItems: 'center',
  padding: '20px',
  borderWidth: 2,
  borderRadius: 2,
  borderColor: '#eeeeee',
  borderStyle: 'dashed',
  backgroundColor: '#fafafa',
  color: '#bdbdbd',
  outline: 'none',
  transition: 'border .24s ease-in-out'
};
const activeStyle = {
  borderColor: '#2196f3'
};
const acceptStyle = {
  borderColor: '#00e676'
};
const rejectStyle = {
  borderColor: '#ff1744'
};
function App(props) {
  const [response, setResponse] = useState("");
  const onDrop = useCallback(acceptedFiles => {
    acceptedFiles.forEach(file => {
      const reader = new FileReader();
      reader.onload = () => {
        const fileAsArrayBuffer = new Uint8Array(reader.result);
          // do whatever you want with the file content
        SetS3Config(process.env.REACT_APP_bucket_name, "protected");
        Storage.put('upload/' + file.name, fileAsArrayBuffer, { contentType: file.type })
        .then(result => {
            console.log(result);
            setResponse("File is uploaded!");
            return true;
        })
        .catch(err => {
            console.log(err);
            setResponse(`File is not uploaded: ${err}`);
            return true;
        });
      };
      reader.onabort = () => {
        console.log("Reading file was aborted");
        setResponse("Reading file was aborted");
      }
      reader.onerror = () => {
        console.log("Reading file has failed");
        setResponse("Reading file has failed");
      }
      reader.readAsArrayBuffer(file);
    });
  }, []);
  const {acceptedFiles, getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject} = useDropzone({onDrop, accept: 'image/tif, image/tiff'});
  const files = acceptedFiles.map(file => (
    <div align="center">
      {file.path} - {file.size} bytes
    </div>
  ));
  const style = useMemo(() => ({
    ...baseStyle,
    ...(isDragActive ? activeStyle : {}),
    ...(isDragAccept ? acceptStyle : {}),
    ...(isDragReject ? rejectStyle : {})
  }), [
    isDragActive,
    isDragAccept,
    isDragReject
  ]);
  if(props.authState === 'signedIn') {
    return (
      <div className="App">
        <div {...getRootProps({style})}>
          <input {...getInputProps()} />
          <p>Drag 'n' drop some files here, or click to select files</p>
        </div>
        <div>
          <h4>Files</h4>
          <p>{files}</p>
        </div>
        {!!response && <div>{response}</div>}
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <p>
            Edit <code>src/App.js</code> and save to reload.
          </p>
          <a
            className="App-link"
            href="https://reactjs.org"
            target="_blank"
            rel="noopener noreferrer"
          >
            Learn React
          </a>
        </header>
      </div>
    );
  } else if(props.authState === 'confirmSignUp') {
    return (
      <div className="Your account is not granted">
        <header className="App-header">
          Your account is not granted.
        </header>
      </div>
    );
  } else if(props.authState === 'signIn') {
    return (
      <div className="Welcome to the world">
        <header className="App-header">
          Welcome to the world.
        </header>
      </div>
    );
  } else {
    return (
      <div className="Login failed">
        <header className="App-header">
          Login failed.
        </header>
      </div>
    );
  }
}
export default App;

App.jsの編集が完了したら、
npm start
します。 Browseが起動して、Login画面が表示されたら、Loginします。Loginすると、UploadするFileをDrag&Dropする領域が出来ています。Uploadする複数のFileをDrag&Dropします。Drag&Dropする領域をクリックして、File Browserから複数のFileを指定することもできます。Uploadが完了したら、Management ConsoleからS3を開き、UploadされたFileを確認します。

これで、 File Upload機能 の実装は完了です。

それでは、以前と同様にS3にDeployしてみましょう。
package.jsonにhomepageのentryが設定されているか確認します。
cd upload
npm run build
aws s3 sync --exact-timestamps --delete --acl public-readdirname bucketname

Management ConsoleからS3を開き、export.hacya.comのindex.htmlのURLを確認して、BrowserでそのURLを開きます。同様にTiff FileをUploadします。

Uploadできました。これで完了です。

Cognitoユーザー認証によるS3 のAccess制御をAmplifyとReactとDropzoneを使って実装する。(その3)

Web Applicationを実装します。「Reactを使ってStaticなWeb Hostingをする。」と同じように、まずは、Reactのひな型を作ります。
create-react-app upload
cd upload
npm install -save aws-amplify aws-amplify-react

前回作成、または、変更したFileを参考に、S3の設定を追加しながら、作成したひな形にCopyします。詳細な説明は、「Cognitoユーザー認証をAmplifyとReactを使って実装する。(その3)」に戻って確認してください。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import AppWithAuth from './AppWithAuth';
import * as serviceWorker from './serviceWorker';

//Import Amplify Configure function
import { configureAmplify } from './services'

configureAmplify();

ReactDOM.render(<AppWithAuth />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();

services.jsでは、S3の設定を追加します。また、SetS3Config()というfunctionを定義します。BucketやS3のAccess Levelを変更した後、AmplifyのStorage.Configure()をCallして、Amplifyの設定を変更する必要があるためです。

import Amplify from '@aws-amplify/core';
import Auth from '@aws-amplify/auth';
import Storage from '@aws-amplify/storage';

export function configureAmplify() {
  Amplify.configure(
  {
    Auth: {
      region: process.env.REACT_APP_region,
      userPoolId: process.env.REACT_APP_userPoolId,
      userPoolWebClientId: process.env.REACT_APP_userPoolWebClientId,
      identityPoolId: process.env.REACT_APP_identityPoolId,
    },
    Storage: {
      AWSS3: {
        bucket: process.env.REACT_APP_bucket_name,
        region: process.env.REACT_APP_region,
      }
    }

  } );
}

//Configure Storage with S3 bucket information
export function SetS3Config(bucket, level){
  Auth.configure({
    region:  process.env.REACT_APP_region,
    identityPoolId: process.env.REACT_APP_identityPoolId,
  });
  Storage.configure({ 
    bucket: bucket,
    level: level,
  });
}

projectのroot directoryの “.env”にS3の設定を追加した、5つの情報を設定ます。
REACT_APP_region=ap-northeast-1
REACT_APP_userPoolId=ap-northeast-1yourPoolID
REACT_APP_userPoolWebClientId=yourWebClientId
REACT_APP_identityPoolId=ap-northeast-1:yourIdentityPoolId
REACT_APP_bucket_name=yourBucketName

AppWithAuth.js、MyVerifyContact.js、MyConfirmSignUp.jsは変更ありません。

import React from 'react';
import { Authenticator, ConfirmSignIn, RequireNewPassword, ConfirmSignUp, VerifyContact, ForgotPassword, TOTPSetup } from 'aws-amplify-react';
import App from "./App";
import MyVerifyContact from "./MyVerifyContact"
import MyConfirmSignUp from "./MyConfirmSignUp"

const signUpConfig = {
  hideAllDefaults: true,
  signUpFields: [
    {
      label: 'Username',
      key: 'username',
      required: true,
      placeholder: 'Username',
      displayOrder: 1
    },
    {
      label: 'Password',
      key: 'password',
      required: true,
      placeholder: 'Password',
      type: 'password',
      displayOrder: 2
    }
  ]
}

function AppWithAuth() {

  function handleAuthStateChange(state, data) {
    if (state === 'confirmSignIn') {
        /* Do something when the user has signed-in */
    }
  }

  return (
    <Authenticator signUpConfig={signUpConfig} hide={[ConfirmSignIn, RequireNewPassword, VerifyContact, ConfirmSignUp, ForgotPassword, TOTPSetup]} onStateChange={handleAuthStateChange}>
      <MyVerifyContact override={VerifyContact} />
      <MyConfirmSignUp override={ConfirmSignUp} />
      <App />
    </Authenticator>
  )
}

export default AppWithAuth;
import { AuthPiece } from 'aws-amplify-react';

class MyVerifyContact extends AuthPiece {
  constructor(props) {
    super(props);
    this._validAuthStates = ["verifyContact"];
    this.state = {}
  }

  static getDerivedStateFromProps(props, state) {
    if(props.authState === "verifyContact") {
      props.onStateChange('signedIn');
      return props;
    } else {
      return null;
    }
  }

  showComponent(theme) {
    return null;
  }

}

export default MyVerifyContact;
import React from 'react';

import { I18n } from 'aws-amplify';
import { AuthPiece } from 'aws-amplify-react';

import {
  FormSection,
  SectionHeader,
  SectionBody,
  SectionFooter,
  Button
} from 'aws-amplify-react';

class MyConfirmSignUp extends AuthPiece {
  constructor(props) {
    super(props);
    this._validAuthStates = ["confirmSignUp"];
    this.gotoSignIn = this.gotoSignIn.bind(this);
  }

  gotoSignIn() {
    super.changeState('signIn');
  }

  showComponent(theme) {
    return (
      <FormSection theme={theme}>
        <SectionHeader theme={theme}>{I18n.get('Please contact your administrator to grant your account.')}</SectionHeader>
        <SectionBody theme={theme}>
        </SectionBody>
        <SectionFooter theme={theme}>
          <Button onClick={this.gotoSignIn} theme={theme}>
            {I18n.get('Goto SignIn')}
          </Button>
        </SectionFooter>
      </FormSection>
    );

  }

}

export default MyConfirmSignUp;

最後にApp.jsを編集して、S3へUploadできるようにします。

import React, { useState, createRef }  from 'react';
import logo from './logo.svg';
import Storage from "@aws-amplify/storage";
import { SetS3Config } from "./services";
import './App.css';

function App(props) {

  const [uploadFile, setUploadFile] = useState({});
  const [response, setResponse] = useState("");
  const uploadRef = createRef();
  const onInputClick = () => {
    if(uploadRef.current) {
      uploadRef.current.value = null;
      uploadRef.current.click();
    }
    setResponse("");
  }

  function uploadFileToS3() {
    SetS3Config(process.env.REACT_APP_bucket_name, "protected");
    Storage.put(`upload/${uploadFile.name}`,
                uploadFile,
                { contentType: uploadFile.type })
      .then(result => {
        console.log(result);
        setUploadFile({});
        setResponse("File is uploaded!");
      })
      .catch(err => {
        console.log(err);
        setResponse(`File is not uploaded: ${err}`);
      });
  };

  function InputUploadFile(props) {
    return(
      <div  align="center">
        <input
          type="file"
          style={{ display: "none" }}
          accept="image/tif, image/tiff"
          ref={uploadRef}
          onChange={(e) => setUploadFile(e.target.files[0])}
        />
        <input value={uploadFile.name} placeholder="Select file" />
        <button onClick={onInputClick}> Browse </button>
        <button onClick={uploadFileToS3}> Upload File </button>

        {!!response && <div>{response}</div>}
      </div>
    )
  }

  if(props.authState === 'signedIn') {
    return (
      <div className="App">
        <InputUploadFile />

        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <p>
            Edit <code>src/App.js</code> and save to reload.
          </p>
          <a
            className="App-link"
            href="https://reactjs.org"
            target="_blank"
            rel="noopener noreferrer"
          >
            Learn React
          </a>
        </header>
      </div>
    );
  } else if(props.authState === 'confirmSignUp') {
    return (
      <div className="Your account is not granted">
        <header className="App-header">
          Your account is not granted.
        </header>
      </div>
    );
  } else if(props.authState === 'signIn') {
    return (
      <div className="Welcome to the world">
        <InputUploadFile />

        <header className="App-header">
          Welcome to the world.
        </header>
      </div>
    );
  } else {
    return (
      <div className="Login failed">
        <header className="App-header">
          Login failed.
        </header>
      </div>
    );
  }
}

export default App;

npm startからの説明は次回。

Cognitoユーザー認証によるS3 のAccess制御をAmplifyとReactとDropzoneを使って実装する。(その2)

Cognitoで認証されたユーザーの権限を設定します。

AWS Serviceを利用する事に対する権限の設定にはIAM UserとIAM Roleの2種類があります。IAM Userは、UserがManagement ConsoleにLoginしてAWS Serviceを利用するための権限設定と言えます。Management Consoleを操作できない仮想のUserを設定することも可能です。個人的な理解はIAM UserはAWS Serviceを外側から利用することを想定した権限設定です。IAM Roleは、AWS Serviceが他のAWS Serviceを利用するための権限設定と言えます。こちらは、AWS Serviceを内側から利用することを想定した権限設定と理解しています。
IAM UserもIAM Roleも個々の権限を直に記述して設定することができます。また、IAM Policyに権限の設定を記述し、複数のIAM PolicyをIAM UserやIAM RoleにAttachして権限を構成することもできます。

Cognitoユーザー認証で認証されたユーザーは、Cognitoで設定されたIAM Roleの権限でAWS Serviceを利用することになります。今回は、Congintoの設定時に生成されたIAM RoleにS3 Accessに必要な権限をIAM Policyに設定し、IAM RoleにAttachします。

Management ConsoleからIAMを開き、「ポリシー」をクリックします。「ポリシーのフィルタ」に「S3」を入力すると、S3の名前を含んだIAM Policyが表示されます。AmazonS3FullAccessの▶をクリックして、ポリシーの書き方を確認します。

「ポリシーの作成」をクリックして、 「JOSN」タブを開き、 ポリシーを設定します。 (その1)で作成したBucketだけにAccess出来るように、(その1)で確認したARNは「arn:aws:s3:::upload.hacya.com」なので、次のJSONのTemplateの{enter bucket name}に「upload.hacya.com」を入れます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::{enter bucket name}/public/*",
                "arn:aws:s3:::{enter bucket name}/protected/${cognito-identity.amazonaws.com:sub}/*",
                "arn:aws:s3:::{enter bucket name}/private/${cognito-identity.amazonaws.com:sub}/*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::{enter bucket name}/uploads/*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::{enter bucket name}/protected/*"
            ],
            "Effect": "Allow"
        },
        {
            "Condition": {
                "StringLike": {
                    "s3:prefix": [
                        "public/",
                        "public/*",
                        "protected/",
                        "protected/*",
                        "private/${cognito-identity.amazonaws.com:sub}/",
                        "private/${cognito-identity.amazonaws.com:sub}/*"
                    ]
                }
            },
            "Action": [
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::{enter bucket name}"
            ],
            "Effect": "Allow"
        }
    ]
}

「ポリシーの確認」をクリックし、ポリシーの確認をします。
ポリシーの確認では、「名前」を設定します。今回は「CognitoS3UploadPolicy」としました。「ポリシーの作成」をクリックし、ポリシーを作成します。

ポリシーが作成できたら、左側の「ロール」をクリックします。ロールが表示されるので、CognitoのUser Poolを作成する時に作成した「Cognito_UserPoolAuth_Role」をクリックします。「ポリシーをアタッチします」をクリックし、「ポリシーのフィルタ」に「S3」を入力し、先ほど作成した 「CognitoS3UploadPolicy」の左側のチェックボックスをクリックしてチェックを入れます。「ポリシーのアタッチ」をクリックして、ポリシーをアタッチします。

これで、Cognitoの認証ユーザーが持つRoleに、S3のAccessに必要な権限を追加したことになります。

Cognitoユーザー認証によるS3 のAccess制御をAmplifyとReactとDropzoneを使って実装する。(その1)

今回は、ユーザーから提供されるFileに対して何かしらの処理を行うことを想定したWeb Serviceの実現の為、File Upload機能を実装します。使うComponentは、今までとほぼ同じく、Cloud側のFile ServiceにS3、ユーザー認証にCognito、Client側はAmplify、Reactを使い、最後にDropZoneによるDrag&Dropを追加します。これにより、Cognitoユーザー認証された 特定多数のUserに対し、S3 BucketにFileをUploadできる機能を実現します。

まずは、File Upload用のS3 Bucketを準備します。Management ConsoleからS3を開きます。前回作成したBucketが見えると思います。「Bucketを作成する」をクリックしてBucketを作成します。 その後のStepは、
1. 「名前とリージョン」タブでバケット名を入力します。バケット名は、FQDN形式で入力します。AWS全体で重複のない名前を指定する必要があります。今回は、”upload.hacya.com”としました。リージョンは”アジアパシフィック(東京)”を選んでおきましょう。
2. 「オプションの設定」タブでは、タグを付けておきましょう。他はデフォルトのままで良いです。
3. 「アクセス許可の設定」タブは、デフォルトのままで良いです。今回は認証されたユーザーにのみ、中身を公開します。
4. 「確認」タブで、内容を確認して「バケットを作成」ボタンをクリックしてBucketを作成します。

作成したBucketのPropertyを見ておきましょう。作成したBucketの左側のチェックボックスをクリックしてチェックを付けると、右側にPop-up Windowが開きPropertyが表示されます。Pop-up Windowsの上の方の「バケットARNをコピーする」をクリックしてARNをクリップボードにコピーし、テキストエディターなどに貼り付け、バケットARNを書き留めておきます。あとで、Policyを作成するときに使います。

次に、作成したBucketにCORSを設定します。CORSは、Cross-Origin Resource Sharingの略で、通常ブラウザは異なるドメインからのデータを読み込めないように設定されています。CORSを設定することで、ブラウザが異なるドメインのデータを読み込めるようにすることができます。例えば、local環境でテストする場合、ReactのApplicationのDefaultでは、http://localhost:3000がOriginとなり、そこからS3のデータを参照する際、例えば東京リージョンのS3のURL、https://s3-ap-northeast-1.amazonaws.comは異なるドメインという事になります。
作成したBucketをクリックして、「アクセス権限」タブをクリック、「CORSの設定」をクリックします。 CORS 構成エディター に、下記の設定を入力し、「保存」をクリックします。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>HEAD</AllowedMethod>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>POST</AllowedMethod>
    <AllowedMethod>DELETE</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <ExposeHeader>x-amz-server-side-encryption</ExposeHeader>
    <ExposeHeader>x-amz-request-id</ExposeHeader>
    <ExposeHeader>x-amz-id-2</ExposeHeader>
    <ExposeHeader>ETag</ExposeHeader>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>

これで、S3の設定は完了です。