├── source ├── client │ ├── .prettierrc │ ├── src │ │ ├── react-app-env.d.ts │ │ ├── liveness │ │ │ ├── pose │ │ │ │ ├── imgs │ │ │ │ │ ├── face.png │ │ │ │ │ ├── mouth │ │ │ │ │ │ ├── smile.png │ │ │ │ │ │ └── closed.png │ │ │ │ │ └── eye │ │ │ │ │ │ ├── left_closed.png │ │ │ │ │ │ ├── left_normal.png │ │ │ │ │ │ ├── right_closed.png │ │ │ │ │ │ ├── right_normal.png │ │ │ │ │ │ ├── left_look_left.png │ │ │ │ │ │ ├── left_look_right.png │ │ │ │ │ │ ├── right_look_left.png │ │ │ │ │ │ └── right_look_right.png │ │ │ │ ├── PoseChallenge.css │ │ │ │ ├── FacePose.ts │ │ │ │ └── PoseChallenge.tsx │ │ │ ├── components │ │ │ │ ├── assets │ │ │ │ │ ├── nose.png │ │ │ │ │ ├── pose.png │ │ │ │ │ ├── negative.png │ │ │ │ │ ├── positive.png │ │ │ │ │ ├── hero │ │ │ │ │ │ ├── hero-1.png │ │ │ │ │ │ ├── hero-2.png │ │ │ │ │ │ ├── hero.png │ │ │ │ │ │ ├── hero@2x.png │ │ │ │ │ │ └── hero@3x.png │ │ │ │ │ ├── wave.svg │ │ │ │ │ ├── Home.svg │ │ │ │ │ ├── eyes.svg │ │ │ │ │ ├── thinking.svg │ │ │ │ │ ├── pose.svg │ │ │ │ │ └── nose.svg │ │ │ │ ├── ResultMessage.css │ │ │ │ ├── SpinnerMessage.css │ │ │ │ ├── SpinnerMessage.tsx │ │ │ │ ├── Welcome.css │ │ │ │ ├── ErrorMessage.tsx │ │ │ │ ├── ResultMessage.tsx │ │ │ │ ├── Welcome.tsx │ │ │ │ └── lottie │ │ │ │ │ └── success.json │ │ │ ├── nose │ │ │ │ ├── NoseChallengeParams.ts │ │ │ │ ├── NoseChallenge.css │ │ │ │ ├── OverlayCanvasDrawer.ts │ │ │ │ ├── StateManager.ts │ │ │ │ ├── NoseChallenge.tsx │ │ │ │ ├── NoseChallengeProcessor.ts │ │ │ │ └── States.ts │ │ │ ├── utils │ │ │ │ ├── LogUtils.ts │ │ │ │ ├── ConfigUtils.ts │ │ │ │ ├── MediaUtils.ts │ │ │ │ ├── APIUtils.ts │ │ │ │ └── CanvasUtils.ts │ │ │ ├── LivenessDetection.css │ │ │ ├── app.scss │ │ │ └── LivenessDetection.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── manifest.json │ │ └── index.html │ ├── tsconfig.json │ ├── .eslintrc.js │ ├── .env │ ├── package.json │ ├── template.yaml │ └── template-one-click.yaml └── backend │ ├── chalicelib │ ├── __init__.py │ ├── jwt_manager.py │ ├── custom.py │ ├── pose.py │ ├── nose.py │ └── framework.py │ ├── requirements.txt │ ├── .chalice │ └── config.json │ ├── app.py │ ├── cognito.yaml │ └── resources.yaml ├── CHANGELOG.md ├── architecture.png ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── CODE_OF_CONDUCT.md ├── .gitignore ├── deployment ├── run-unit-tests.sh ├── liveness-detection-framework.yaml └── build-s3-dist.sh ├── NOTICE ├── CONTRIBUTING.md └── LICENSE /source/client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /source/client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.0.0] - 2022-01-04 4 | ### Added 5 | - First version 6 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/architecture.png -------------------------------------------------------------------------------- /source/client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /source/client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/public/favicon.ico -------------------------------------------------------------------------------- /source/backend/chalicelib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/backend/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.20.11 2 | botocore==1.23.11 3 | chalice==1.26.2 4 | numpy==1.22.0 5 | aws-lambda-powertools==1.22.0 6 | PyJWT==2.4.0 7 | -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/face.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/mouth/smile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/mouth/smile.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/nose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/nose.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/pose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/pose.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/mouth/closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/mouth/closed.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/negative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/negative.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/positive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/positive.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/left_closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/left_closed.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/left_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/left_normal.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/right_closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/right_closed.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/right_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/right_normal.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/hero/hero-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/hero/hero-1.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/hero/hero-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/hero/hero-2.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/hero/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/hero/hero.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/left_look_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/left_look_left.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/left_look_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/left_look_right.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/right_look_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/right_look_left.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/hero/hero@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/hero/hero@2x.png -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/hero/hero@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/components/assets/hero/hero@3x.png -------------------------------------------------------------------------------- /source/client/src/liveness/pose/imgs/eye/right_look_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/liveness-detection-framework/main/source/client/src/liveness/pose/imgs/eye/right_look_right.png -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /source/client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Liveness", 3 | "name": "Liveness Detection", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/ResultMessage.css: -------------------------------------------------------------------------------- 1 | .result-frame { 2 | position: relative; 3 | padding-top: 1rem; 4 | width: 170px; 5 | margin: 2rem auto 0 auto; 6 | } 7 | .result-animation { 8 | position: absolute; 9 | right: 0; 10 | top: 0; 11 | } 12 | .result-face { 13 | width: 150px; 14 | border: 1px solid white; 15 | border-radius: 1rem; 16 | box-shadow: rgba(0, 0, 0, 0.25) 0px 24px 48px -10px; 17 | } 18 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/SpinnerMessage.css: -------------------------------------------------------------------------------- 1 | .spinner-frame { 2 | position: relative; 3 | padding-top: 1rem; 4 | width: 170px; 5 | margin: 2rem auto 0 auto; 6 | } 7 | .eyes-face { 8 | position: absolute; 9 | width: 67px; 10 | top: 98px; 11 | left: 49px; 12 | animation: 0.4s ease-in-out infinite alternate movingEyes; 13 | } 14 | @keyframes movingEyes { 15 | from { 16 | left: 49px; 17 | } 18 | to { 19 | left: 51px; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | **/dist 3 | **/global-s3-assets 4 | **/regional-s3-assets 5 | **/open-source 6 | **/.zip 7 | **/tmp 8 | **/out-tsc 9 | **/client/build 10 | **/backend/.chalice/deployments/ 11 | 12 | # dependencies 13 | **/node_modules 14 | 15 | # e2e 16 | **/e2e/*.js 17 | **/e2e/*.map 18 | 19 | # misc 20 | **/npm-debug.log 21 | **/testem.log 22 | **/.vscode/settings.json 23 | .idea 24 | 25 | # System Files 26 | **/.DS_Store 27 | **/.vscode 28 | 29 | __pycache__/ 30 | -------------------------------------------------------------------------------- /source/backend/.chalice/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "app_name": "liveness-backend", 4 | "lambda_functions": { 5 | "api_handler": { 6 | "lambda_timeout": 120, 7 | "lambda_memory_size": 512 8 | } 9 | }, 10 | "stages": { 11 | "dev": { 12 | "api_gateway_stage": "dev", 13 | "environment_variables": { 14 | "REGION_NAME": "us-east-1", 15 | "THREAD_POOL_SIZE": "10", 16 | "LOG_LEVEL": "DEBUG", 17 | "CLIENT_CHALLENGE_SELECTION": "True" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this solution 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the feature you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /source/client/src/liveness/nose/NoseChallengeParams.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | export interface NoseChallengeParams { 7 | readonly imageHeight: number; 8 | readonly imageWidth: number; 9 | readonly areaHeight: number; 10 | readonly areaLeft: number; 11 | readonly areaTop: number; 12 | readonly areaWidth: number; 13 | readonly noseHeight: number; 14 | readonly noseLeft: number; 15 | readonly noseTop: number; 16 | readonly noseWidth: number; 17 | } 18 | -------------------------------------------------------------------------------- /source/client/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | body { 7 | margin: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 9 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 10 | sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 17 | monospace; 18 | } 19 | -------------------------------------------------------------------------------- /source/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | import ReactDOM from "react-dom"; 8 | import { AmplifyAuthenticator } from "@aws-amplify/ui-react"; 9 | 10 | import LivenessDetection from "./liveness/LivenessDetection"; 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById("root") 19 | ); 20 | -------------------------------------------------------------------------------- /deployment/run-unit-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This assumes all of the OS-level configuration has been completed and git repo has already been cloned 4 | # 5 | # This script should be run from the repo's deployment directory 6 | # cd deployment 7 | # ./run-unit-tests.sh 8 | # 9 | 10 | # Get reference for all important folders 11 | # template_dir="$PWD" 12 | # source_dir="$template_dir/../source" 13 | 14 | # TODO: Temporary till unit tests are added 15 | echo "------------------------------------------------------------------------------" 16 | echo "No tests to run" 17 | echo "------------------------------------------------------------------------------" 18 | -------------------------------------------------------------------------------- /source/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "importHelpers": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "strict": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "jsx": "react-jsx" 25 | }, 26 | "include": [ 27 | "src" 28 | ], 29 | "exclude": [ 30 | "node_modules" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /source/client/src/liveness/nose/NoseChallenge.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | #cameraVideo { 7 | width: 100%; 8 | max-width: 640px; 9 | } 10 | 11 | #cameraVideo, 12 | #overlayCanvas { 13 | position: absolute; 14 | } 15 | 16 | .rotate { 17 | transform: translate(-50%) rotateY(180deg); 18 | } 19 | 20 | .videoContainer { 21 | position: relative; 22 | width: 100%; 23 | max-width: 640px; 24 | box-sizing: border-box; 25 | text-align: center; 26 | } 27 | 28 | .helpContainer { 29 | height: 100px; 30 | width: 100%; 31 | } 32 | 33 | .messageContainer { 34 | height: 8em; 35 | max-width: 70%; 36 | text-align: left; 37 | } 38 | 39 | .message { 40 | height: 4em; 41 | margin-top: 2em; 42 | } 43 | -------------------------------------------------------------------------------- /source/backend/chalicelib/jwt_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import jwt 5 | 6 | from aws_lambda_powertools.utilities import parameters 7 | 8 | 9 | class JwtManager: 10 | JWT_ALGORITHM = 'HS256' 11 | 12 | def __init__(self, token_secret): 13 | self.secret = parameters.get_secret(token_secret) if token_secret else None 14 | 15 | def get_jwt_token(self, challenge_id): 16 | payload = { 17 | 'challengeId': challenge_id 18 | } 19 | return jwt.encode(payload, self.secret, algorithm=JwtManager.JWT_ALGORITHM) 20 | 21 | def get_challenge_id(self, jwt_token): 22 | decoded = jwt.decode(jwt_token, self.secret, algorithms=JwtManager.JWT_ALGORITHM) 23 | return decoded['challengeId'] 24 | -------------------------------------------------------------------------------- /source/client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "react-app", 4 | "react-app/jest", 5 | "eslint:recommended", 6 | "plugin:react/recommended", 7 | "plugin:prettier/recommended", 8 | "plugin:@typescript-eslint/eslint-recommended" 9 | ], 10 | parser: "@typescript-eslint/parser", 11 | parserOptions: { 12 | ecmaVersion: 2021, 13 | sourceType: "module", 14 | ecmaFeatures: { 15 | jsx: true 16 | } 17 | }, 18 | rules: { 19 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 20 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 21 | "@typescript-eslint/ban-ts-comment": "off", 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/no-this-alias": "off", 24 | "@typescript-eslint/explicit-module-boundary-types": "off" 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /source/client/src/liveness/utils/LogUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { Logger } from "aws-amplify"; 7 | 8 | const LOGGER_NAME = "LivenessDetection"; 9 | 10 | let LOG_LEVEL = "DEBUG"; 11 | if (process.env.NODE_ENV === "production") { 12 | LOG_LEVEL = "ERROR"; 13 | } 14 | 15 | export class LogUtils { 16 | private static logger = new Logger(LOGGER_NAME, LOG_LEVEL); 17 | 18 | public static debug(...msg: any[]): void { 19 | LogUtils.logger.debug(...msg); 20 | } 21 | 22 | public static info(...msg: any[]): void { 23 | LogUtils.logger.info(...msg); 24 | } 25 | 26 | public static warn(...msg: any[]): void { 27 | LogUtils.logger.warn(...msg); 28 | } 29 | 30 | public static error(...msg: any[]): void { 31 | LogUtils.logger.error(...msg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /source/client/src/liveness/pose/PoseChallenge.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | #video-camera { 7 | width: 100%; 8 | max-width: 640px; 9 | position: absolute; 10 | } 11 | 12 | #text-info { 13 | height: 50px; 14 | line-height: 50px; 15 | text-align: center; 16 | background: rgba(24, 12, 12, 0.51); 17 | color: white; 18 | } 19 | 20 | .rotate { 21 | transform: translate(-50%) rotateY(180deg); 22 | } 23 | 24 | .videoContainer { 25 | position: relative; 26 | width: 100%; 27 | max-width: 640px; 28 | box-sizing: border-box; 29 | text-align: center; 30 | } 31 | 32 | .take-button { 33 | position: absolute; 34 | } 35 | .hidden { 36 | display: none; 37 | } 38 | .p-relative { 39 | position: relative; 40 | } 41 | .check-canvas { 42 | background-color: #f2f3f3; 43 | padding: 1rem; 44 | border-radius: 1rem; 45 | } 46 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/SpinnerMessage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | import thinkingFace from "./assets/thinking.svg"; 8 | import eyes from "./assets/eyes.svg"; 9 | import "./SpinnerMessage.css"; 10 | 11 | type Props = { 12 | message: string; 13 | }; 14 | 15 | export default class SpinnerMessage extends React.Component { 16 | render() { 17 | return ( 18 |
19 |
20 | Thinking face 21 | Eyes 22 |
23 |

{this.props.message}

24 |

Wait few seconds

25 |
26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/Welcome.css: -------------------------------------------------------------------------------- 1 | .header { 2 | background: url("./assets/wave.svg") no-repeat top center; 3 | background-size: auto; 4 | } 5 | 6 | .title-background { 7 | font-weight: 800; 8 | text-align: left; 9 | margin-bottom: 2rem; 10 | padding-top: 6rem; 11 | } 12 | .title-background span { 13 | font-weight: 400; 14 | } 15 | 16 | .challenge-options { 17 | border: 1px solid #d5dbdb; 18 | border-radius: 1rem; 19 | margin-bottom: 0.5rem; 20 | padding: 0.5rem 1rem; 21 | } 22 | .challenge-options:hover { 23 | /* background-color: #f2f3f3; */ 24 | box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; 25 | } 26 | .small { 27 | color: #545b64; 28 | } 29 | .challenge-image { 30 | width: 80px; 31 | } 32 | .challenge-options .option-content { 33 | text-align: left; 34 | } 35 | .challenge-options input { 36 | margin: 0.25rem 0.5rem 0 0; 37 | } 38 | .challenge-options label { 39 | font-weight: 800; 40 | margin-bottom: 0; 41 | } 42 | -------------------------------------------------------------------------------- /source/client/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_DRAW_DETECTIONS=false 2 | REACT_APP_API_NAME=liveness-backend 3 | REACT_APP_API_URL=%%ENDPOINT_URL%% 4 | REACT_APP_USER_POOL_ID=%%USER_POOL_ID%% 5 | REACT_APP_USER_POOL_WEB_CLIENT_ID=%%USER_POOL_WEB_CLIENT_ID%% 6 | REACT_APP_AWS_REGION=%%AWS_REGION%% 7 | REACT_APP_API_START_ENDPOINT=/challenge 8 | REACT_APP_API_FRAMES_ENDPOINT_PATTERN=/challenge/{challengeId}/frame 9 | REACT_APP_API_VERIFY_ENDPOINT_PATTERN=/challenge/{challengeId}/verify 10 | REACT_APP_MAX_IMAGE_WIDTH=480 11 | REACT_APP_MAX_IMAGE_HEIGHT=480 12 | REACT_APP_IMAGE_JPG_QUALITY=0.7 13 | REACT_APP_STATE_AREA_DURATION_IN_SECONDS=30 14 | REACT_APP_STATE_NOSE_DURATION_IN_SECONDS=10 15 | REACT_APP_STATE_AREA_MAX_FRAMES_WITHOUT_FACE=6 16 | REACT_APP_STATE_NOSE_MAX_FRAMES_WITHOUT_FACE=12 17 | REACT_APP_MAX_FPS=12 18 | REACT_APP_FACE_AREA_TOLERANCE_PERCENT=20 19 | REACT_APP_MIN_FACE_AREA_PERCENT=60 20 | REACT_APP_FLIP_VIDEO=true 21 | REACT_APP_LANDMARK_INDEX=30 22 | REACT_APP_MIN_FRAMES_FACE_STATE=3 23 | -------------------------------------------------------------------------------- /source/backend/app.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import os 5 | import secrets 6 | from importlib import import_module 7 | 8 | from chalice import Chalice 9 | 10 | from chalicelib.framework import blueprint, challenge_type_selector 11 | 12 | LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') 13 | CLIENT_CHALLENGE_SELECTION = os.getenv('CLIENT_CHALLENGE_SELECTION', "False").upper() == 'TRUE' 14 | 15 | app = Chalice(app_name='liveness-backend') 16 | app.log.setLevel(LOG_LEVEL) 17 | app.register_blueprint(blueprint) 18 | 19 | import_module('chalicelib.nose') 20 | import_module('chalicelib.pose') 21 | import_module('chalicelib.custom') 22 | 23 | 24 | @challenge_type_selector 25 | def random_challenge_selector(client_metadata): 26 | app.log.debug('random_challenge_selector') 27 | if CLIENT_CHALLENGE_SELECTION and 'challengeType' in client_metadata: 28 | return client_metadata['challengeType'] 29 | return secrets.choice(['POSE', 'NOSE']) 30 | -------------------------------------------------------------------------------- /source/client/src/liveness/LivenessDetection.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | .LivenessDetection { 7 | text-align: center; 8 | } 9 | 10 | .LivenessDetection-logo { 11 | height: 40vmin; 12 | pointer-events: none; 13 | } 14 | 15 | @media (prefers-reduced-motion: no-preference) { 16 | .LivenessDetection-logo { 17 | animation: LivenessDetection-logo-spin infinite 20s linear; 18 | } 19 | } 20 | 21 | .LivenessDetection-header { 22 | background-color: #282c34; 23 | min-height: 100vh; 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | justify-content: center; 28 | font-size: calc(10px + 2vmin); 29 | color: white; 30 | } 31 | 32 | .LivenessDetection-link { 33 | color: #61dafb; 34 | } 35 | 36 | .gray-darker { 37 | color: #545b64; 38 | } 39 | 40 | @keyframes LivenessDetection-logo-spin { 41 | from { 42 | transform: rotate(0deg); 43 | } 44 | to { 45 | transform: rotate(360deg); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/wave.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /source/backend/chalicelib/custom.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import logging 5 | 6 | from .framework import STATE_NEXT, CHALLENGE_SUCCESS # , STATE_CONTINUE, CHALLENGE_FAIL 7 | from .framework import challenge_params, challenge_state 8 | 9 | _log = logging.getLogger('liveness-backend') 10 | 11 | 12 | @challenge_params(challenge_type='CUSTOM') 13 | def custom_challenge_params(client_metadata): 14 | params = dict() 15 | params.update(client_metadata) 16 | return params 17 | 18 | 19 | @challenge_state(challenge_type='CUSTOM', first=True, next_state='second_state') 20 | def first_state(_params, _frame, _context): 21 | # To continue in the same state, use 'return STATE_CONTINUE' instead 22 | return STATE_NEXT 23 | 24 | 25 | @challenge_state(challenge_type='CUSTOM', next_state='second_state') 26 | def second_state(_params, _frame, _context): 27 | # To continue in the same state, use 'return STATE_CONTINUE' instead 28 | return STATE_NEXT 29 | 30 | 31 | @challenge_state(challenge_type='CUSTOM') 32 | def last_state(_params, _frame, _context): 33 | # If the challenge fails, use 'return CHALLENGE_FAIL' instead 34 | return CHALLENGE_SUCCESS 35 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/Home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /source/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liveness-react", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@aws-amplify/ui-react": "^1.2.23", 7 | "@types/jest": "^26.0.15", 8 | "@types/node": "^12.0.0", 9 | "@types/react": "^17.0.0", 10 | "@types/react-dom": "^17.0.0", 11 | "@typescript-eslint/eslint-plugin": "^5.30.5", 12 | "@typescript-eslint/parser": "^5.30.5", 13 | "aws-amplify": "^4.3.10", 14 | "bootstrap": "^4.6.0", 15 | "eslint": "^7.24.0", 16 | "eslint-config-prettier": "^8.2.0", 17 | "eslint-plugin-prettier": "^3.3.1", 18 | "eslint-plugin-react": "^7.23.2", 19 | "face-api.js": "^0.22.2", 20 | "prettier": "^1.19.1", 21 | "react": "^17.0.2", 22 | "react-dom": "^17.0.2", 23 | "react-lottie": "^1.2.3", 24 | "react-scripts": "^5.0.1", 25 | "sass": "^1.35.2", 26 | "typescript": "^4.1.2", 27 | "web-vitals": "^1.0.1" 28 | }, 29 | "scripts": { 30 | "start": "react-scripts start", 31 | "build": "react-scripts build", 32 | "eject": "react-scripts eject" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.3%", 37 | "not ie 11", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | ">0.3%", 43 | "not ie 11", 44 | "not dead", 45 | "not op_mini all" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /source/client/src/liveness/app.scss: -------------------------------------------------------------------------------- 1 | $gray-100: #f2f3f3; 2 | $gray-300: #d5dbdb; 3 | $gray-500: #aab7b8; 4 | $gray-700: #545b64; 5 | $gray-900: #16191f; 6 | $black: #000; 7 | 8 | $red: #dc2a2a !default; 9 | $yellow: #8d6708 !default; 10 | $green: #1d5240 !default; 11 | 12 | $primary: #16191f; 13 | $secondary: #ededed; 14 | $success: $green; 15 | $info: $gray-500; 16 | $warning: $yellow; 17 | $danger: $red; 18 | $light: $gray-100; 19 | $dark: $gray-700; 20 | 21 | $enable-shadows: false; 22 | 23 | $box-shadow-sm: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; 24 | $box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; 25 | $box-shadow-lg: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; 26 | 27 | $body-color: $gray-900; 28 | 29 | @import url("https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;700&display=swap"); 30 | $font-family-sans-serif: "Fira Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, 31 | "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", 32 | "Noto Color Emoji"; 33 | 34 | $border-radius: 0.8rem; 35 | $border-radius-lg: 0.8rem; 36 | $border-radius-sm: 0.8rem; 37 | 38 | $input-btn-padding-y: 0.5rem; 39 | $input-btn-padding-x: 1rem; 40 | 41 | $container-max-widths: ( 42 | sm: 540px, 43 | md: 720px, 44 | lg: 720px, 45 | xl: 720px 46 | ); 47 | 48 | @import "node_modules/bootstrap/scss/bootstrap"; 49 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | 8 | type Props = { 9 | message: string; 10 | onRestart: () => void; 11 | }; 12 | 13 | export default class ErrorMessage extends React.Component { 14 | render() { 15 | return ( 16 |
17 | 25 | 26 | 30 | 31 | 32 |

Something went wrong

33 |

34 | {this.props.message} 35 |

36 | 39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/eyes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Please complete the following information about the solution:** 20 | - [ ] Version: [e.g. v1.0.0] 21 | 22 | To get the version of the solution, you can look at the description of the created CloudFormation stack. For example, "_(SO0021) - Video On Demand workflow with AWS Step Functions, MediaConvert, MediaPackage, S3, CloudFront and DynamoDB. Version **v5.0.0**_". If the description does not contain the version information, you can look at the mappings section of the template: 23 | 24 | ```yaml 25 | Mappings: 26 | SourceCode: 27 | General: 28 | S3Bucket: "solutions" 29 | KeyPrefix: "video-on-demand-on-aws/v5.0.0" 30 | ``` 31 | 32 | - [ ] Region: [e.g. us-east-1] 33 | - [ ] Was the solution modified from the version published on this repository? 34 | - [ ] If the answer to the previous question was yes, are the changes available on GitHub? 35 | - [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for the sevices this solution uses? 36 | - [ ] Were there any errors in the CloudWatch Logs? 37 | 38 | **Screenshots** 39 | If applicable, add screenshots to help explain your problem (please **DO NOT include sensitive information**). 40 | 41 | **Additional context** 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /source/client/src/liveness/nose/OverlayCanvasDrawer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as faceapi from "face-api.js"; 7 | 8 | export class DrawColors { 9 | public static readonly RED = "rgba(255, 71, 71)"; 10 | public static readonly GREEN = "rgba(29, 82, 64, 1)"; 11 | public static readonly YELLOW = "rgba(229, 167, 9, 1)"; 12 | } 13 | 14 | export interface DrawBoxOptions { 15 | readonly boxHeight: number; 16 | readonly boxLeft: number; 17 | readonly boxTop: number; 18 | readonly boxWidth: number; 19 | readonly boxColor?: string; 20 | readonly lineWidth?: number; 21 | } 22 | 23 | export interface DrawOptions { 24 | readonly faceDrawBoxOptions?: DrawBoxOptions; 25 | readonly noseDrawBoxOptions?: DrawBoxOptions; 26 | } 27 | 28 | export class OverlayCanvasDrawer { 29 | private readonly overlayCanvasElement: HTMLCanvasElement; 30 | 31 | constructor(overlayCanvasElement: HTMLCanvasElement) { 32 | this.overlayCanvasElement = overlayCanvasElement; 33 | } 34 | 35 | draw(drawOptions: DrawOptions) { 36 | if (drawOptions.faceDrawBoxOptions) { 37 | this.drawArea(drawOptions.faceDrawBoxOptions); 38 | } 39 | if (drawOptions.noseDrawBoxOptions) { 40 | this.drawArea(drawOptions.noseDrawBoxOptions); 41 | } 42 | } 43 | 44 | private drawArea(drawBoxOptions: DrawBoxOptions) { 45 | const box = { 46 | x: drawBoxOptions.boxLeft, 47 | y: drawBoxOptions.boxTop, 48 | width: drawBoxOptions.boxWidth, 49 | height: drawBoxOptions.boxHeight 50 | }; 51 | const drawBox = new faceapi.draw.DrawBox(box, drawBoxOptions); 52 | drawBox.draw(this.overlayCanvasElement); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /source/client/src/liveness/utils/ConfigUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | export interface Config { 7 | DRAW_DETECTIONS: string; 8 | API_NAME: string; 9 | API_URL: string; 10 | API_START_ENDPOINT: string; 11 | API_VERIFY_ENDPOINT_PATTERN: string; 12 | API_FRAMES_ENDPOINT_PATTERN: string; 13 | MAX_IMAGE_WIDTH: string; 14 | MAX_IMAGE_HEIGHT: string; 15 | IMAGE_JPG_QUALITY: string; 16 | STATE_AREA_DURATION_IN_SECONDS: string; 17 | STATE_NOSE_DURATION_IN_SECONDS: string; 18 | STATE_AREA_MAX_FRAMES_WITHOUT_FACE: string; 19 | STATE_NOSE_MAX_FRAMES_WITHOUT_FACE: string; 20 | MAX_FPS: string; 21 | FACE_AREA_TOLERANCE_PERCENT: string; 22 | MIN_FACE_AREA_PERCENT: string; 23 | FLIP_VIDEO: string; 24 | LANDMARK_INDEX: string; 25 | MIN_FRAMES_FACE_STATE: string; 26 | } 27 | 28 | export class ConfigUtils { 29 | private static KEY_PREFIX = "REACT_APP_"; 30 | 31 | static loadConfig() { 32 | const envKeys = Object.keys(process.env); 33 | const map = new Map(); 34 | envKeys.forEach(envKey => { 35 | const key = envKey.replace(ConfigUtils.KEY_PREFIX, ""); 36 | const value = process.env[envKey] as string; 37 | map.set(key, value); 38 | }); 39 | (window as any).config = Object.fromEntries(map); 40 | } 41 | 42 | static getConfig(): Config { 43 | return (window as any).config as Config; 44 | } 45 | 46 | static getConfigBooleanValue(configKey: string): boolean { 47 | return ( 48 | new Map(Object.entries(ConfigUtils.getConfig())) 49 | .get(configKey) 50 | .trim() 51 | .toLowerCase() === "true" 52 | ); 53 | } 54 | } 55 | 56 | ConfigUtils.loadConfig(); 57 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Liveness Detection Framework 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | Licensed under the Apache License Version 2.0 (the "License"). You may not use this file except 5 | in compliance with the License. A copy of the License is located at http://www.apache.org/licenses/ 6 | or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, 7 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the 8 | specific language governing permissions and limitations under the License. 9 | 10 | ********************** 11 | THIRD PARTY COMPONENTS 12 | ********************** 13 | This software includes third party software subject to the following copyrights: 14 | 15 | AWS SDK under the Apache License Version 2.0 16 | @types/jest under MIT license 17 | @types/node under MIT license 18 | @types/react under MIT license 19 | @types/react-dom under MIT license 20 | @typescript-eslint/eslint-plugin under MIT license 21 | @typescript-eslint/parser under BSD-2-Clause license 22 | aws-amplify under Apache-2.0 license 23 | bootstrap under MIT license 24 | eslint under MIT license 25 | eslint-config-prettier under MIT license 26 | eslint-plugin-prettier under MIT license 27 | eslint-plugin-react under MIT license 28 | face-api.js under MIT license 29 | prettier under MIT license 30 | react under MIT license 31 | react-dom under MIT license 32 | react-lottie under MIT license 33 | react-scripts under MIT license 34 | sass under MIT license 35 | typescript under Apache-2.0 license 36 | web-vitals under Apache-2.0 license 37 | PyJWT under MIT License 38 | aws-lambda-powertools under MIT License 39 | boto3 under Apache Software License 40 | botocore under Apache Software License 41 | chalice under Apache Software License 42 | numpy under BSD License 43 | -------------------------------------------------------------------------------- /source/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 26 | Liveness Detection 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /source/client/src/liveness/utils/MediaUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { ConfigUtils } from "./ConfigUtils"; 7 | import { LogUtils } from "./LogUtils"; 8 | 9 | export interface MediaStreamInfo { 10 | mediaStream: MediaStream; 11 | actualHeight: number; 12 | actualWidth: number; 13 | } 14 | 15 | export class MediaUtils { 16 | static loadMediaStream(successCallback: () => void, errorCallback: (message: string) => void) { 17 | const constraints: MediaStreamConstraints = { 18 | audio: false, 19 | video: { 20 | width: { 21 | ideal: window.innerWidth, 22 | max: parseInt(ConfigUtils.getConfig().MAX_IMAGE_WIDTH) 23 | }, 24 | height: { 25 | ideal: window.innerWidth, 26 | max: parseInt(ConfigUtils.getConfig().MAX_IMAGE_HEIGHT) 27 | }, 28 | facingMode: "user", 29 | aspectRatio: 1.0 30 | } 31 | }; 32 | navigator.mediaDevices 33 | .getUserMedia(constraints) 34 | .then((mediaStream: MediaStream) => { 35 | try { 36 | const mediaStreamInfo = { 37 | mediaStream: mediaStream, 38 | actualHeight: mediaStream.getVideoTracks()[0].getSettings().height, 39 | actualWidth: mediaStream.getVideoTracks()[0].getSettings().width 40 | }; 41 | LogUtils.info( 42 | `media info: actualHeight=${mediaStreamInfo.actualHeight} actualWidth=${mediaStreamInfo.actualWidth}` 43 | ); 44 | (window as any).mediaStreamInfo = mediaStreamInfo; 45 | } catch (error) { 46 | LogUtils.error(error); 47 | errorCallback("Error getting video actual sizes"); 48 | } 49 | successCallback(); 50 | }) 51 | .catch(error => { 52 | LogUtils.error(error); 53 | errorCallback("Error getting access to the camera"); 54 | }); 55 | } 56 | 57 | static getMediaStreamInfo(): MediaStreamInfo { 58 | return (window as any).mediaStreamInfo as MediaStreamInfo; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/ResultMessage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | // @ts-ignore 8 | import Lottie from "react-lottie"; 9 | import * as successData from "./lottie/success.json"; 10 | import * as failData from "./lottie/fail.json"; 11 | import positiveFace from "./assets/positive.png"; 12 | import negativeFace from "./assets/negative.png"; 13 | import "./ResultMessage.css"; 14 | 15 | const SUCCESS_TITLE = "Liveness verified"; 16 | const SUCCESS_MESSAGE = "Successfully verified as a live person."; 17 | const SUCCESS_BUTTON = "Start another challenge"; 18 | const FAIL_TITLE = "Unable to validate liveness"; 19 | const FAIL_MESSAGE = 20 | "Pay attention to the eyes, the mouth or the tip of your nose, depending on the challenge. Make sure your face is well-illuminated"; 21 | const FAIL_BUTTON = "Try again"; 22 | 23 | type Props = { 24 | success: boolean; 25 | onRestart: () => void; 26 | }; 27 | 28 | export default class ResultMessage extends React.Component { 29 | render() { 30 | const title = this.props.success ? SUCCESS_TITLE : FAIL_TITLE; 31 | const message = this.props.success ? SUCCESS_MESSAGE : FAIL_MESSAGE; 32 | const buttonText = this.props.success ? SUCCESS_BUTTON : FAIL_BUTTON; 33 | const resultImg = this.props.success ? positiveFace : negativeFace; 34 | const lottieOptions = { 35 | // @ts-ignore 36 | animationData: this.props.success ? successData.default : failData.default, 37 | loop: false 38 | }; 39 | 40 | return ( 41 |
42 |
43 |
44 | 45 |
46 | Positive face 47 |
48 |

{title}

49 |

{message}

50 | 53 |
54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /source/backend/cognito.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). 4 | # You may not use this file except in compliance with the License. 5 | # A copy of the License is located at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # or in the "license" file accompanying this file. This file is distributed 10 | # on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | # express or implied. See the License for the specific language governing 12 | # permissions and limitations under the License. 13 | AWSTemplateFormatVersion: 2010-09-09 14 | 15 | Description: Liveness Detection Framework - user authentication 16 | 17 | Parameters: 18 | AdminEmail: 19 | Type: String 20 | Description: The email of the system administrator 21 | AllowedPattern: '[^\s@]+@[^\s@]+\.[^\s@]+' 22 | ConstraintDescription: Must be a valid email address 23 | AdminName: 24 | Type: String 25 | MinLength: 1 26 | MaxLength: 2048 27 | Description: The name of the system administrator 28 | 29 | Resources: 30 | UserPool: 31 | Type: AWS::Cognito::UserPool 32 | Properties: 33 | AdminCreateUserConfig: 34 | AllowAdminCreateUserOnly: true 35 | UserPoolName: LivenessUserPool 36 | Policies: 37 | PasswordPolicy: 38 | MinimumLength: 6 39 | RequireLowercase: true 40 | RequireNumbers: true 41 | RequireSymbols: true 42 | RequireUppercase: true 43 | UserPoolClient: 44 | Type: AWS::Cognito::UserPoolClient 45 | Properties: 46 | UserPoolId: !Ref UserPool 47 | GenerateSecret: false 48 | ExplicitAuthFlows: 49 | - USER_PASSWORD_AUTH 50 | ReadAttributes: 51 | - name 52 | - email 53 | - email_verified 54 | AdminlUser: 55 | Type: AWS::Cognito::UserPoolUser 56 | Properties: 57 | UserPoolId: !Ref UserPool 58 | DesiredDeliveryMediums: 59 | - EMAIL 60 | Username: admin 61 | UserAttributes: 62 | - Name: name 63 | Value: !Ref AdminName 64 | - Name: email 65 | Value: !Ref AdminEmail 66 | - Name: email_verified 67 | Value: True 68 | 69 | Outputs: 70 | CognitoUserPoolArn: 71 | Value: !GetAtt UserPool.Arn 72 | UserPoolId: 73 | Value: !Ref UserPool 74 | UserPoolWebClientId: 75 | Value: !Ref UserPoolClient 76 | -------------------------------------------------------------------------------- /source/client/src/liveness/nose/StateManager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as faceapi from "face-api.js"; 7 | import { NoseChallengeParams } from "./NoseChallengeParams"; 8 | import { DrawOptions } from "./OverlayCanvasDrawer"; 9 | import { State, StateOutput, FaceState, NoseState, FailState, SuccessState } from "./States"; 10 | import { LogUtils } from "../utils/LogUtils"; 11 | 12 | export interface StateManagerOutput { 13 | readonly end: boolean; 14 | readonly success?: boolean; 15 | readonly shouldSaveFrame: boolean; 16 | readonly drawOptions?: DrawOptions; 17 | readonly helpMessage?: string; 18 | readonly helpAnimationNumber?: number; 19 | } 20 | 21 | export class StateManager { 22 | private readonly noseChallengeParams: NoseChallengeParams; 23 | 24 | private currentState!: State; 25 | private endTime!: number; 26 | 27 | constructor(noseChallengeParams: NoseChallengeParams) { 28 | this.noseChallengeParams = noseChallengeParams; 29 | this.changeCurrentState(new FaceState(this.noseChallengeParams)); 30 | } 31 | 32 | process( 33 | result: faceapi.WithFaceLandmarks<{ detection: faceapi.FaceDetection }, faceapi.FaceLandmarks68>[] 34 | ): StateManagerOutput { 35 | LogUtils.debug(`current state: ${this.currentState.getName()}`); 36 | 37 | if (this.endTime > 0 && Date.now() / 1000 > this.endTime) { 38 | LogUtils.info(`fail: state timed out`); 39 | this.changeCurrentState(new FailState(this.noseChallengeParams)); 40 | } 41 | const stateOutput: StateOutput = this.currentState.process(result); 42 | if (stateOutput.nextState) { 43 | this.changeCurrentState(stateOutput.nextState); 44 | } 45 | 46 | let end = false; 47 | let shouldSaveFrame = false; 48 | let success; 49 | if (this.currentState.getName() === SuccessState.NAME) { 50 | end = true; 51 | success = true; 52 | shouldSaveFrame = true; 53 | } else if (this.currentState.getName() === FailState.NAME) { 54 | end = true; 55 | success = false; 56 | } else if (this.currentState.getName() === NoseState.NAME) { 57 | shouldSaveFrame = true; 58 | } 59 | return { 60 | end: end, 61 | success: success, 62 | shouldSaveFrame: shouldSaveFrame, 63 | drawOptions: stateOutput.drawOptions, 64 | helpMessage: stateOutput.helpMessage, 65 | helpAnimationNumber: stateOutput.helpAnimationNumber 66 | }; 67 | } 68 | 69 | private changeCurrentState(state: State) { 70 | if (this.currentState !== state) { 71 | this.currentState = state; 72 | this.endTime = 73 | state.getMaximumDurationInSeconds() !== -1 ? Date.now() / 1000 + state.getMaximumDurationInSeconds() : -1; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /source/client/src/liveness/utils/APIUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { Amplify, API, Auth } from "aws-amplify"; 7 | import { ConfigUtils } from "./ConfigUtils"; 8 | import { MediaUtils } from "./MediaUtils"; 9 | import { LogUtils } from "./LogUtils"; 10 | 11 | Amplify.configure({ 12 | Auth: { 13 | region: process.env.REACT_APP_AWS_REGION, 14 | userPoolId: process.env.REACT_APP_USER_POOL_ID, 15 | userPoolWebClientId: process.env.REACT_APP_USER_POOL_WEB_CLIENT_ID 16 | }, 17 | API: { 18 | endpoints: [ 19 | { 20 | name: process.env.REACT_APP_API_NAME, 21 | endpoint: process.env.REACT_APP_API_URL 22 | } 23 | ] 24 | } 25 | }); 26 | 27 | export interface ChallengeMetadata { 28 | readonly id: string; 29 | readonly token: string; 30 | readonly type: string; 31 | readonly params: unknown; 32 | } 33 | 34 | export interface ChallengeResult { 35 | readonly success: boolean; 36 | } 37 | 38 | export class APIUtils { 39 | static async getAuthorizationHeader() { 40 | return { 41 | Authorization: `Bearer ${(await Auth.currentSession()).getIdToken().getJwtToken()}` 42 | }; 43 | } 44 | 45 | static async startChallenge(challengeType: string): Promise { 46 | const path = ConfigUtils.getConfig().API_START_ENDPOINT; 47 | const init = { 48 | headers: await APIUtils.getAuthorizationHeader(), 49 | body: { 50 | imageWidth: MediaUtils.getMediaStreamInfo().actualWidth, 51 | imageHeight: MediaUtils.getMediaStreamInfo().actualHeight 52 | } 53 | }; 54 | 55 | if (challengeType) { 56 | // @ts-ignore 57 | init.body["challengeType"] = challengeType; 58 | } 59 | 60 | LogUtils.info("startChallenge:"); 61 | LogUtils.info(init); 62 | return API.post(ConfigUtils.getConfig().API_NAME, path, init).then(result => { 63 | LogUtils.info(result); 64 | return result; 65 | }); 66 | } 67 | 68 | static async putChallengeFrame( 69 | challengeId: string, 70 | token: string, 71 | frameBase64: string, 72 | timestamp: number 73 | ): Promise { 74 | const path: string = ConfigUtils.getConfig().API_FRAMES_ENDPOINT_PATTERN.replace("{challengeId}", challengeId); 75 | const init = { 76 | headers: await APIUtils.getAuthorizationHeader(), 77 | body: { 78 | token: token, 79 | timestamp: timestamp, 80 | frameBase64: frameBase64 81 | } 82 | }; 83 | LogUtils.info("putChallengeFrame:"); 84 | LogUtils.info(init); 85 | return API.put(ConfigUtils.getConfig().API_NAME, path, init).then(result => { 86 | LogUtils.info(result); 87 | return result; 88 | }); 89 | } 90 | 91 | static async verifyChallenge(challengeId: string, token: string): Promise { 92 | const path: string = ConfigUtils.getConfig().API_VERIFY_ENDPOINT_PATTERN.replace("{challengeId}", challengeId); 93 | const init = { 94 | headers: await APIUtils.getAuthorizationHeader(), 95 | body: { 96 | token: token 97 | } 98 | }; 99 | LogUtils.info("verifyChallenge"); 100 | LogUtils.info(init); 101 | return API.post(ConfigUtils.getConfig().API_NAME, path, init).then(result => { 102 | LogUtils.info(result); 103 | return result; 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /source/client/src/liveness/utils/CanvasUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | export class CanvasUtils { 7 | private static getVideoElement(elementId: string): HTMLVideoElement { 8 | const videoElement = document.getElementById(elementId) as HTMLVideoElement; 9 | if (!videoElement) { 10 | throw Error(`Video element ${elementId} not found`); 11 | } 12 | return videoElement; 13 | } 14 | 15 | public static getCanvasElement(elementId: string): HTMLCanvasElement { 16 | const canvasElement = document.getElementById(elementId) as HTMLCanvasElement; 17 | if (!canvasElement) { 18 | throw Error(`Canvas element ${elementId} not found`); 19 | } 20 | return canvasElement; 21 | } 22 | 23 | public static getCanvasContext(canvas: HTMLCanvasElement) { 24 | const context = canvas.getContext("2d"); 25 | if (context === null) { 26 | throw Error("Error getting canvas context"); 27 | } 28 | return context; 29 | } 30 | 31 | public static setVideoElementSrc(videoElementId: string, mediaStream: MediaStream) { 32 | const videoElement = CanvasUtils.getVideoElement(videoElementId); 33 | videoElement.srcObject = mediaStream; 34 | } 35 | 36 | public static takePhoto( 37 | videoElementId: string, 38 | canvasElementId: string, 39 | width: number, 40 | height: number, 41 | flip: boolean 42 | ) { 43 | const videoElement = CanvasUtils.getVideoElement(videoElementId); 44 | const canvasElement = CanvasUtils.getCanvasElement(canvasElementId); 45 | const canvasContext = CanvasUtils.getCanvasContext(canvasElement); 46 | canvasElement.width = width; 47 | canvasElement.height = height; 48 | if (flip) { 49 | canvasContext.scale(-1, 1); 50 | canvasContext.drawImage(videoElement, 0, 0, width * -1, height); 51 | } else { 52 | canvasContext.drawImage(videoElement, 0, 0); 53 | } 54 | } 55 | 56 | public static getPhotoFromCanvas(canvasElementId: string, jpgQuality: string) { 57 | const canvasElement = CanvasUtils.getCanvasElement(canvasElementId); 58 | const image = canvasElement.toDataURL("image/jpeg", jpgQuality); 59 | return image.substr(image.indexOf(",") + 1); 60 | } 61 | 62 | public static getScaleFactor(canvasElement: HTMLCanvasElement, image: HTMLImageElement | HTMLCanvasElement) { 63 | const widthScaleFactor = canvasElement.width / image.width; 64 | const heightScaleFactor = canvasElement.height / image.height; 65 | return Math.min(widthScaleFactor, heightScaleFactor); 66 | } 67 | 68 | public static drawImageInCanvas( 69 | canvasElementId: string, 70 | image: HTMLImageElement | HTMLCanvasElement, 71 | dx: number, 72 | dy: number, 73 | scaleFactor: number 74 | ) { 75 | const canvasElement = CanvasUtils.getCanvasElement(canvasElementId); 76 | const canvasContext = CanvasUtils.getCanvasContext(canvasElement); 77 | canvasContext.drawImage(image, dx, dy, image.width * scaleFactor, image.height * scaleFactor); 78 | } 79 | 80 | public static drawScaledCanvasInCanvas(sourceCanvasElementId: string, destinationCanvasElementId: string) { 81 | CanvasUtils.drawImageInCanvas( 82 | destinationCanvasElementId, 83 | CanvasUtils.getCanvasElement(sourceCanvasElementId), 84 | 0, 85 | 0, 86 | CanvasUtils.getScaleFactor( 87 | CanvasUtils.getCanvasElement(destinationCanvasElementId), 88 | CanvasUtils.getCanvasElement(sourceCanvasElementId) 89 | ) 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /source/client/src/liveness/pose/FacePose.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import imgFace from "./imgs/face.png"; 7 | import imgMouthClosed from "./imgs/mouth/closed.png"; 8 | import imgMouthSmile from "./imgs/mouth/smile.png"; 9 | import imgEyeLeftClosed from "./imgs/eye/left_closed.png"; 10 | import imgEyeLeftNormal from "./imgs/eye/left_normal.png"; 11 | import imgEyeLeftLookingLeft from "./imgs/eye/left_look_left.png"; 12 | import imgEyeLeftLookingRight from "./imgs/eye/left_look_right.png"; 13 | import imgEyeRightClosed from "./imgs/eye/right_closed.png"; 14 | import imgEyeRightNormal from "./imgs/eye/right_normal.png"; 15 | import imgEyeRightLookingLeft from "./imgs/eye/right_look_left.png"; 16 | import imgEyeRightLookingRight from "./imgs/eye/right_look_right.png"; 17 | import { CanvasUtils } from "../utils/CanvasUtils"; 18 | 19 | const LEFT_EYE_X = 140; 20 | const RIGHT_EYE_X = 354; 21 | const EYE_Y = 290; 22 | const MOUTH_X = 244; 23 | const MOUTH_Y = 556; 24 | 25 | type ImgSrcMap = Record; 26 | const EYE_LEFT_IMG_SRC_MAP: ImgSrcMap = { 27 | OPEN: imgEyeLeftNormal, 28 | CLOSED: imgEyeLeftClosed, 29 | LOOKING_LEFT: imgEyeLeftLookingLeft, 30 | LOOKING_RIGHT: imgEyeLeftLookingRight 31 | }; 32 | const EYE_RIGHT_IMG_SRC_MAP: ImgSrcMap = { 33 | OPEN: imgEyeRightNormal, 34 | CLOSED: imgEyeRightClosed, 35 | LOOKING_LEFT: imgEyeRightLookingLeft, 36 | LOOKING_RIGHT: imgEyeRightLookingRight 37 | }; 38 | const MOUTH_IMG_SRC_MAP: ImgSrcMap = { 39 | CLOSED: imgMouthClosed, 40 | SMILE: imgMouthSmile 41 | }; 42 | 43 | export class FacePose { 44 | private readonly eyes: string; 45 | private readonly mouth: string; 46 | 47 | constructor(eyes: string, mouth: string) { 48 | this.eyes = eyes; 49 | this.mouth = mouth; 50 | } 51 | 52 | public draw(canvasElementId: string) { 53 | const mouthImageSrc = MOUTH_IMG_SRC_MAP[this.mouth]; 54 | const eyeLeftImageSrc = EYE_LEFT_IMG_SRC_MAP[this.eyes]; 55 | const eyeRightImageSrc = EYE_RIGHT_IMG_SRC_MAP[this.eyes]; 56 | const faceImage = new Image(); 57 | faceImage.src = imgFace; 58 | faceImage.onload = function() { 59 | const canvasElement = CanvasUtils.getCanvasElement(canvasElementId); 60 | const scaleFactor = CanvasUtils.getScaleFactor(canvasElement, faceImage); 61 | const marginX = (canvasElement.width - faceImage.width * scaleFactor) / 2; 62 | const marginY = (canvasElement.height - faceImage.height * scaleFactor) / 2; 63 | CanvasUtils.drawImageInCanvas(canvasElementId, faceImage, marginX, marginY, scaleFactor); 64 | FacePose.drawImage( 65 | canvasElementId, 66 | mouthImageSrc, 67 | MOUTH_X * scaleFactor + marginX, 68 | MOUTH_Y * scaleFactor + marginY, 69 | scaleFactor 70 | ); 71 | FacePose.drawImage( 72 | canvasElementId, 73 | eyeLeftImageSrc, 74 | LEFT_EYE_X * scaleFactor + marginX, 75 | EYE_Y * scaleFactor + marginY, 76 | scaleFactor 77 | ); 78 | FacePose.drawImage( 79 | canvasElementId, 80 | eyeRightImageSrc, 81 | RIGHT_EYE_X * scaleFactor + marginX, 82 | EYE_Y * scaleFactor + marginY, 83 | scaleFactor 84 | ); 85 | }; 86 | } 87 | 88 | private static drawImage(canvasElementId: string, imageSrc: string, dx: number, dy: number, scaleFactor: number) { 89 | const image = new Image(); 90 | image.src = imageSrc; 91 | image.onload = function() { 92 | CanvasUtils.drawImageInCanvas(canvasElementId, image, dx, dy, scaleFactor); 93 | }; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /deployment/liveness-detection-framework.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). 4 | # You may not use this file except in compliance with the License. 5 | # A copy of the License is located at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # or in the "license" file accompanying this file. This file is distributed 10 | # on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | # express or implied. See the License for the specific language governing 12 | # permissions and limitations under the License. 13 | AWSTemplateFormatVersion: 2010-09-09 14 | 15 | Description: (%%SOLUTION_ID%%) - Liveness Detection Framework %%VERSION%% - Main template 16 | 17 | Parameters: 18 | AdminEmail: 19 | Type: String 20 | Description: The email of the system administrator 21 | AllowedPattern: '[^\s@]+@[^\s@]+\.[^\s@]+' 22 | ConstraintDescription: Must be a valid email address 23 | AdminName: 24 | Type: String 25 | MinLength: 1 26 | MaxLength: 2048 27 | Description: The name of the system administrator 28 | 29 | Mappings: 30 | MetricsMap: 31 | Send-Data: 32 | SendAnonymousData: True # change to 'False' if needed 33 | SourceCode: 34 | General: 35 | S3Bucket: "%%BUCKET_NAME%%" 36 | KeyPrefix: "%%SOLUTION_NAME%%/%%VERSION%%" 37 | AWSSDK: 38 | UserAgent: 39 | Extra: "AWSSOLUTION/%%SOLUTION_ID%%/%%VERSION%%" 40 | 41 | Resources: 42 | CognitoStack: 43 | Type: 'AWS::CloudFormation::Stack' 44 | Properties: 45 | TemplateURL: !Sub 46 | - https://s3.amazonaws.com/${S3Bucket}/${S3Key} 47 | - S3Bucket: !Join ["-", [!FindInMap ["SourceCode", "General", "S3Bucket"]%%SUFFIX_REF%%]] 48 | S3Key: !Join ["/", [!FindInMap ["SourceCode", "General", "KeyPrefix"], "cognito.template"]] 49 | Parameters: 50 | AdminEmail: !Ref AdminEmail 51 | AdminName: !Ref AdminName 52 | BackendStack: 53 | Type: 'AWS::CloudFormation::Stack' 54 | DependsOn: CognitoStack 55 | Properties: 56 | TemplateURL: !Sub 57 | - https://s3.amazonaws.com/${S3Bucket}/${S3Key} 58 | - S3Bucket: !Join ["-", [!FindInMap ["SourceCode", "General", "S3Bucket"]%%SUFFIX_REF%%]] 59 | S3Key: !Join ["/", [!FindInMap ["SourceCode", "General", "KeyPrefix"], "backend.template"]] 60 | Parameters: 61 | SendAnonymousData: !FindInMap ["MetricsMap", "Send-Data", "SendAnonymousData"] 62 | SolutionIdentifier: !FindInMap ["AWSSDK", "UserAgent", "Extra"] 63 | LambdaCodeUriBucket: !Join ["-", [!FindInMap ["SourceCode", "General", "S3Bucket"]%%SUFFIX_REGION%%]] 64 | LambdaCodeUriKey: !Join [ "/", [ !FindInMap [ "SourceCode", "General", "KeyPrefix" ], "deployment.zip" ] ] 65 | CognitoUserPoolArn: !GetAtt CognitoStack.Outputs.CognitoUserPoolArn 66 | ClientStack: 67 | Type: 'AWS::CloudFormation::Stack' 68 | DependsOn: BackendStack 69 | Properties: 70 | TemplateURL: !Sub 71 | - https://s3.amazonaws.com/${S3Bucket}/${S3Key} 72 | - S3Bucket: !Join ["-", [!FindInMap ["SourceCode", "General", "S3Bucket"]%%SUFFIX_REF%%]] 73 | S3Key: !Join ["/", [!FindInMap ["SourceCode", "General", "KeyPrefix"], "client.template"]] 74 | Parameters: 75 | EndpointURL: !GetAtt BackendStack.Outputs.EndpointURL 76 | BuildBucket: !Join ["-", [!FindInMap ["SourceCode", "General", "S3Bucket"]%%SUFFIX_REGION%%]] 77 | BuildKey: !Join ["/", [!FindInMap ["SourceCode", "General", "KeyPrefix"], "%%CLIENT_BUILD_KEY%%"]] 78 | UserPoolId: !GetAtt CognitoStack.Outputs.UserPoolId 79 | UserPoolWebClientId: !GetAtt CognitoStack.Outputs.UserPoolWebClientId 80 | 81 | Outputs: 82 | Url: 83 | Value: !GetAtt ClientStack.Outputs.WebsiteURL 84 | -------------------------------------------------------------------------------- /source/client/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: Liveness Detection Framework - web application static files 3 | Resources: 4 | StaticWebsiteBucket: 5 | Type: AWS::S3::Bucket 6 | DeletionPolicy: Retain 7 | Properties: 8 | VersioningConfiguration: 9 | Status: Enabled 10 | PublicAccessBlockConfiguration: 11 | BlockPublicAcls: true 12 | BlockPublicPolicy: true 13 | IgnorePublicAcls: true 14 | RestrictPublicBuckets: true 15 | BucketEncryption: 16 | ServerSideEncryptionConfiguration: 17 | - ServerSideEncryptionByDefault: 18 | SSEAlgorithm: AES256 19 | LoggingConfiguration: 20 | DestinationBucketName: !Ref LoggingBucket 21 | LogFilePrefix: "s3-static-website-bucket/" 22 | LoggingBucket: 23 | Type: AWS::S3::Bucket 24 | DeletionPolicy: Retain 25 | Properties: 26 | VersioningConfiguration: 27 | Status: Enabled 28 | PublicAccessBlockConfiguration: 29 | BlockPublicAcls: true 30 | BlockPublicPolicy: true 31 | IgnorePublicAcls: true 32 | RestrictPublicBuckets: true 33 | AccessControl: LogDeliveryWrite 34 | BucketEncryption: 35 | ServerSideEncryptionConfiguration: 36 | - ServerSideEncryptionByDefault: 37 | SSEAlgorithm: AES256 38 | Metadata: 39 | cfn_nag: 40 | rules_to_suppress: 41 | - id: W35 42 | reason: S3 Bucket access logging not needed here. 43 | LoggingBucketPolicy: 44 | Type: AWS::S3::BucketPolicy 45 | Properties: 46 | Bucket: !Ref LoggingBucket 47 | PolicyDocument: 48 | Statement: 49 | - Effect: Deny 50 | Principal: "*" 51 | Action: "*" 52 | Resource: 53 | - !Sub "arn:aws:s3:::${LoggingBucket}/*" 54 | - !Sub "arn:aws:s3:::${LoggingBucket}" 55 | Condition: 56 | Bool: 57 | aws:SecureTransport: false 58 | CloudFrontOriginAccessIdentity: 59 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 60 | Properties: 61 | CloudFrontOriginAccessIdentityConfig: 62 | Comment: "S3 CloudFront OAI" 63 | CloudFrontDistribution: 64 | Type: AWS::CloudFront::Distribution 65 | Properties: 66 | DistributionConfig: 67 | DefaultCacheBehavior: 68 | ForwardedValues: 69 | QueryString: false 70 | TargetOriginId: the-s3-bucket 71 | ViewerProtocolPolicy: redirect-to-https 72 | DefaultRootObject: index.html 73 | Enabled: true 74 | Logging: 75 | Bucket: !Sub "${LoggingBucket}.s3.amazonaws.com" 76 | Prefix: "cloudfront-distribution/" 77 | Origins: 78 | - DomainName: !Sub "${StaticWebsiteBucket}.s3.${AWS::Region}.amazonaws.com" 79 | Id: the-s3-bucket 80 | S3OriginConfig: 81 | OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" 82 | Metadata: 83 | cfn_nag: 84 | rules_to_suppress: 85 | - id: W70 86 | reason: Minimum protocol version not supported with distribution that uses the CloudFront domain name. 87 | BucketPolicy: 88 | Type: AWS::S3::BucketPolicy 89 | Properties: 90 | Bucket: !Ref StaticWebsiteBucket 91 | PolicyDocument: 92 | Statement: 93 | - Effect: Allow 94 | Action: 95 | - s3:GetObject 96 | Principal: 97 | CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId 98 | Resource: !Sub "arn:aws:s3:::${StaticWebsiteBucket}/*" 99 | - Effect: Deny 100 | Action: "*" 101 | Principal: "*" 102 | Resource: 103 | - !Sub "arn:aws:s3:::${StaticWebsiteBucket}/*" 104 | - !Sub "arn:aws:s3:::${StaticWebsiteBucket}" 105 | Condition: 106 | Bool: 107 | aws:SecureTransport: false 108 | Outputs: 109 | StaticWebsiteBucket: 110 | Value: !Ref StaticWebsiteBucket 111 | WebsiteURL: 112 | Value: !Sub "https://${CloudFrontDistribution.DomainName}/" 113 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/thinking.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /source/client/src/liveness/LivenessDetection.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | import { Hub } from "aws-amplify"; 8 | import "./LivenessDetection.css"; 9 | import "./app.scss"; 10 | import Welcome from "./components/Welcome"; 11 | import SpinnerMessage from "./components/SpinnerMessage"; 12 | import ResultMessage from "./components/ResultMessage"; 13 | import ErrorMessage from "./components/ErrorMessage"; 14 | import NoseChallenge from "./nose/NoseChallenge"; 15 | import PoseChallenge from "./pose/PoseChallenge"; 16 | import { APIUtils, ChallengeMetadata, ChallengeResult } from "./utils/APIUtils"; 17 | import { LogUtils } from "./utils/LogUtils"; 18 | 19 | type Props = Record; 20 | 21 | type State = { 22 | challengeMetadata: ChallengeMetadata; 23 | success: boolean; 24 | step: number; 25 | errorMessage: string; 26 | loading: boolean; 27 | }; 28 | 29 | export default class LivenessDetection extends React.Component { 30 | constructor(props: Props | Readonly) { 31 | super(props); 32 | this.state = { 33 | challengeMetadata: { 34 | id: "", 35 | token: "", 36 | type: "", 37 | params: {} 38 | }, 39 | success: false, 40 | step: 1, 41 | errorMessage: "", 42 | loading: false 43 | }; 44 | this.onStart = this.onStart.bind(this); 45 | this.onLocalEnd = this.onLocalEnd.bind(this); 46 | this.onRestart = this.onRestart.bind(this); 47 | this.onError = this.onError.bind(this); 48 | 49 | Hub.listen("auth", (data: any): void => { 50 | if (data.payload.event === "signOut") { 51 | window.location.reload(); 52 | } 53 | }); 54 | } 55 | 56 | onStart(challengeType: string): void { 57 | this.setState({ 58 | loading: true 59 | }); 60 | const self = this; 61 | APIUtils.startChallenge(challengeType) 62 | .then((challengeMetadata: ChallengeMetadata) => { 63 | this.setState({ challengeMetadata: challengeMetadata }); 64 | this.setState({ step: 2 }); 65 | }) 66 | .catch((error: Error) => { 67 | this.onError(error); 68 | }) 69 | .finally(() => { 70 | self.setState({ 71 | loading: false 72 | }); 73 | }); 74 | } 75 | 76 | onLocalEnd(localSuccess: boolean): void { 77 | if (localSuccess) { 78 | this.setState({ step: 3 }); 79 | APIUtils.verifyChallenge(this.state.challengeMetadata.id, this.state.challengeMetadata.token) 80 | .then((result: ChallengeResult) => { 81 | this.setState({ success: result.success }); 82 | this.setState({ step: 4 }); 83 | }) 84 | .catch((error: Error) => { 85 | this.onError(error); 86 | }); 87 | } else { 88 | this.setState({ success: false }); 89 | this.setState({ step: 4 }); 90 | } 91 | } 92 | 93 | onRestart(): void { 94 | this.setState({ step: 1 }); 95 | } 96 | 97 | onError(error: Error): void { 98 | LogUtils.error(error); 99 | this.setState({ errorMessage: error.name + ": " + error.message }); 100 | this.setState({ step: -1 }); 101 | } 102 | 103 | render() { 104 | return ( 105 |
106 | {this.state.step === 1 && ( 107 | 108 | )} 109 | {this.state.step === 2 && this.state.challengeMetadata.type === "NOSE" && ( 110 | 115 | )} 116 | {this.state.step === 2 && this.state.challengeMetadata.type === "POSE" && ( 117 | 122 | )} 123 | {this.state.step === 3 && } 124 | {this.state.step === 4 && } 125 | {this.state.step === -1 && } 126 |
127 | ); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /source/client/src/liveness/nose/NoseChallenge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | import "./NoseChallenge.css"; 8 | // @ts-ignore 9 | import Lottie from "react-lottie"; 10 | import SpinnerMessage from "../components/SpinnerMessage"; 11 | import { NoseChallengeProcessor } from "./NoseChallengeProcessor"; 12 | import { ChallengeMetadata } from "../utils/APIUtils"; 13 | import { ConfigUtils } from "../utils/ConfigUtils"; 14 | import { MediaUtils } from "../utils/MediaUtils"; 15 | import * as help1 from "./lottie/help1.json"; 16 | import * as help2 from "./lottie/help2.json"; 17 | 18 | type Props = { 19 | challengeMetadata: ChallengeMetadata; 20 | onLocalEnd: (localSuccess: boolean) => void; 21 | onError: (error: Error) => void; 22 | }; 23 | 24 | type State = { 25 | message: string; 26 | animation: number; 27 | localSuccess: boolean; 28 | uploading: boolean; 29 | }; 30 | 31 | export default class NoseChallenge extends React.Component { 32 | constructor(props: Props | Readonly) { 33 | super(props); 34 | this.state = { 35 | message: "Loading...", 36 | animation: -1, 37 | localSuccess: false, 38 | uploading: false 39 | }; 40 | this.onHelpMessage = this.onHelpMessage.bind(this); 41 | this.onHelpAnimation = this.onHelpAnimation.bind(this); 42 | this.onLocalEnd = this.onLocalEnd.bind(this); 43 | this.onUploadEnd = this.onUploadEnd.bind(this); 44 | } 45 | 46 | componentDidMount() { 47 | // Make sure all models are loaded before starting frame processing 48 | NoseChallengeProcessor.loadModels().then(() => { 49 | new NoseChallengeProcessor( 50 | this.props.challengeMetadata, 51 | "cameraVideo", 52 | "overlayCanvas", 53 | this.onLocalEnd, 54 | this.onUploadEnd, 55 | this.onHelpMessage, 56 | this.onHelpAnimation 57 | ).start(); 58 | }); 59 | } 60 | 61 | onLocalEnd(localSuccess: boolean) { 62 | this.setState({ uploading: true }); 63 | this.setState({ localSuccess: localSuccess }); 64 | } 65 | 66 | onUploadEnd() { 67 | this.props.onLocalEnd(this.state.localSuccess); 68 | } 69 | 70 | onHelpMessage(message: string | undefined): void { 71 | this.setState({ message: message || "" }); 72 | } 73 | 74 | onHelpAnimation(animationNumber: number | undefined): void { 75 | this.setState({ animation: animationNumber || -1 }); 76 | } 77 | 78 | render() { 79 | const videoWidth = MediaUtils.getMediaStreamInfo().actualWidth; 80 | const videoHeight = MediaUtils.getMediaStreamInfo().actualHeight; 81 | const shouldRotate = ConfigUtils.getConfigBooleanValue("FLIP_VIDEO"); 82 | // @ts-ignore 83 | const lottieOptions1 = { animationData: help1.default }; 84 | // @ts-ignore 85 | const lottieOptions2 = { animationData: help2.default }; 86 | 87 | return ( 88 |
89 | {!this.state.uploading && ( 90 |
91 |
122 | )} 123 | {this.state.uploading && } 124 |
125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/Welcome.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | import { AmplifySignOut } from "@aws-amplify/ui-react"; 8 | // @ts-ignore 9 | import Lottie from "react-lottie"; 10 | import { MediaUtils } from "../utils/MediaUtils"; 11 | import * as welcomeData from "./lottie/intro.json"; 12 | import "./Welcome.css"; 13 | import faceChallenge from "./assets/pose.png"; 14 | 15 | type Props = { 16 | onStart: (challengeType: string) => void; 17 | onError: (error: Error) => void; 18 | loading: boolean; 19 | }; 20 | 21 | type State = { 22 | mediaStreamReady: boolean; 23 | challengeType: string; 24 | }; 25 | 26 | export default class Welcome extends React.Component { 27 | constructor(props: Props | Readonly) { 28 | super(props); 29 | this.state = { mediaStreamReady: false, challengeType: "" }; 30 | this.onChallengeTypeChanged = this.onChallengeTypeChanged.bind(this); 31 | } 32 | 33 | onChallengeTypeChanged(event: React.ChangeEvent) { 34 | this.setState({ 35 | challengeType: event.target.value 36 | }); 37 | } 38 | 39 | componentDidMount() { 40 | MediaUtils.loadMediaStream( 41 | () => { 42 | this.setState({ mediaStreamReady: true }); 43 | }, 44 | message => { 45 | this.props.onError(Error(message)); 46 | } 47 | ); 48 | } 49 | 50 | render() { 51 | const lottieOptions = { 52 | // @ts-ignore 53 | animationData: welcomeData.default, 54 | loop: true 55 | }; 56 | 57 | return ( 58 | <> 59 |
60 |
61 |

62 | Liveness Detection Framework 63 |

64 | 65 |

Choose one challenge to validate liveness

66 |
this.setState({ challengeType: "NOSE" })} 69 | > 70 |
71 | 79 |
80 | 81 |

Place the tip of your nose in the target area

82 |
83 |
84 | {/* Nose Challenge */} 85 | 93 |
94 |
this.setState({ challengeType: "POSE" })} 97 | > 98 |
99 | 107 |
108 | 109 |

Copy a facial expression

110 |
111 |
112 | Face Challenge 113 |
114 | 115 | {!this.props.loading && ( 116 | 124 | )} 125 | {this.props.loading &&
} 126 |
127 |
128 |
129 | 130 |
131 |
132 |
133 | 134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /source/backend/chalicelib/pose.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import logging 5 | import secrets 6 | 7 | from .framework import challenge_params, challenge_state 8 | from .framework import CHALLENGE_SUCCESS, CHALLENGE_FAIL 9 | 10 | _log = logging.getLogger('liveness-backend') 11 | 12 | POSE_EYS = ['OPEN', 'CLOSED', 'LOOKING_LEFT', 'LOOKING_RIGHT'] 13 | POSE_MOUTH = ['CLOSED', 'SMILE'] 14 | 15 | REKOGNITION_FACE_MIN_CONFIDENCE = 90 16 | REKOGNITION_FACE_MAX_ROTATION = 20 17 | EYE_DIRECTION_AREA_MULTIPLIER = 1.2 # the bigger the value, more permissive 18 | 19 | 20 | @challenge_params(challenge_type='POSE') 21 | def pose_challenge_params(client_metadata): 22 | image_width = int(client_metadata['imageWidth']) 23 | image_height = int(client_metadata['imageHeight']) 24 | params = dict() 25 | params['imageWidth'] = image_width 26 | params['imageHeight'] = image_height 27 | params['pose'] = { 28 | 'eyes': secrets.choice(POSE_EYS), 29 | 'mouth': secrets.choice(POSE_MOUTH) 30 | } 31 | return params 32 | 33 | 34 | @challenge_state(challenge_type='POSE', first=True) 35 | def first_state(params, frame, _context): 36 | _log.debug(f'Params: {params}') 37 | _log.debug(f'Frame: {frame}') 38 | 39 | faces = frame['rekMetadata'] 40 | num_faces = len(faces) 41 | _log.debug(f'Number of faces: {num_faces}') 42 | if num_faces != 1: 43 | _log.info(f'FAIL: Number of faces. Expected: 1 Actual: {num_faces}') 44 | return CHALLENGE_FAIL 45 | 46 | face = faces[0] 47 | confidence = face['Confidence'] 48 | _log.debug(f'Confidence: {confidence}') 49 | if face['Confidence'] < REKOGNITION_FACE_MIN_CONFIDENCE: 50 | _log.info(f'FAIL: Confidence. Expected: {REKOGNITION_FACE_MIN_CONFIDENCE} Actual: {confidence}') 51 | return CHALLENGE_FAIL 52 | 53 | rotation_pose = face['Pose'] 54 | _log.debug(f'Rotation: {rotation_pose}') 55 | if _is_rotated(rotation_pose): 56 | _log.info(f'FAIL: Face rotation. Expected: {REKOGNITION_FACE_MAX_ROTATION} Actual: {rotation_pose}') 57 | return CHALLENGE_FAIL 58 | 59 | expected_eyes = params['pose']['eyes'] 60 | if not _are_eyes_correct(expected_eyes, face): 61 | _log.info(f'FAIL: Eyes. Expected: {expected_eyes}') 62 | return CHALLENGE_FAIL 63 | 64 | expected_mouth = params['pose']['mouth'] 65 | if not _is_mouth_correct(expected_mouth, face): 66 | _log.info(f'FAIL: Mouth. Expected: {expected_mouth}') 67 | return CHALLENGE_FAIL 68 | 69 | _log.info(f'Success!') 70 | return CHALLENGE_SUCCESS 71 | 72 | 73 | def _is_rotated(pose): 74 | return (abs(pose['Roll']) > REKOGNITION_FACE_MAX_ROTATION or 75 | abs(pose['Yaw']) > REKOGNITION_FACE_MAX_ROTATION or 76 | abs(pose['Pitch']) > REKOGNITION_FACE_MAX_ROTATION) 77 | 78 | 79 | def _is_mouth_correct(expected, face): 80 | should_smile = expected == 'SMILE' 81 | is_smiling = face['Smile']['Value'] 82 | is_mouth_open = face['MouthOpen']['Value'] 83 | _log.debug(f'Smiling: {is_smiling} Mouth open: {is_mouth_open}') 84 | return (should_smile and is_smiling and is_mouth_open) or ( 85 | not should_smile and not is_smiling and not is_mouth_open) 86 | 87 | 88 | def _are_eyes_correct(expected, face): 89 | are_open = face['EyesOpen']['Value'] 90 | _log.debug(f'Eyes open: {are_open}') 91 | if (expected == 'CLOSED' and are_open) or (expected != 'CLOSED' and not are_open): 92 | return False 93 | 94 | eye_left, eye_right = _get_eyes_coordinates(face['Landmarks']) 95 | _log.debug(f'Eyes coordinates - Left: {eye_left} Right: {eye_right}') 96 | eye_left_direction = _get_eye_direction(eye_left) 97 | _log.debug(f'Left eye direction: {eye_left_direction}') 98 | if _is_eye_opposite_direction(eye_left_direction, expected): 99 | _log.debug(f'Wrong left eye direction. Expected: {expected} Actual: {eye_left_direction}') 100 | return False 101 | eye_right_direction = _get_eye_direction(eye_right) 102 | _log.debug(f'Right eye direction: {eye_right_direction}') 103 | if _is_eye_opposite_direction(eye_right_direction, expected): 104 | _log.debug(f'Wrong right eye direction. Expected: {expected} Actual: {eye_right_direction}') 105 | return False 106 | return True 107 | 108 | 109 | def _get_eyes_coordinates(landmarks): 110 | eye_left = {} 111 | eye_right = {} 112 | for landmark in landmarks: 113 | if landmark['Type'] == 'rightEyeLeft': 114 | eye_right['left'] = {'x': landmark['X'], 'y': landmark['Y']} 115 | elif landmark['Type'] == 'rightEyeRight': 116 | eye_right['right'] = {'x': landmark['X'], 'y': landmark['Y']} 117 | elif landmark['Type'] == 'rightPupil': 118 | eye_right['pupil'] = {'x': landmark['X'], 'y': landmark['Y']} 119 | elif landmark['Type'] == 'leftEyeLeft': 120 | eye_left['left'] = {'x': landmark['X'], 'y': landmark['Y']} 121 | elif landmark['Type'] == 'leftEyeRight': 122 | eye_left['right'] = {'x': landmark['X'], 'y': landmark['Y']} 123 | elif landmark['Type'] == 'leftPupil': 124 | eye_left['pupil'] = {'x': landmark['X'], 'y': landmark['Y']} 125 | return eye_left, eye_right 126 | 127 | 128 | def _get_eye_direction(eye): 129 | one_third_of_eye_width = (eye['right']['x'] - eye['left']['x']) / 3 130 | if eye['pupil']['x'] <= eye['left']['x'] + one_third_of_eye_width * EYE_DIRECTION_AREA_MULTIPLIER: 131 | return 'LOOKING_LEFT' 132 | elif eye['pupil']['x'] >= eye['right']['x'] - one_third_of_eye_width * EYE_DIRECTION_AREA_MULTIPLIER: 133 | return 'LOOKING_RIGHT' 134 | return 'OPEN' 135 | 136 | 137 | def _is_eye_opposite_direction(direction, expected): 138 | return (direction == 'LOOKING_LEFT' and expected == 'LOOKING_RIGHT') or ( 139 | direction == 'LOOKING_RIGHT' and expected == 'LOOKING_LEFT') 140 | -------------------------------------------------------------------------------- /deployment/build-s3-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This assumes all of the OS-level configuration has been completed and git repo has already been cloned 4 | # 5 | # This script should be run from the repo's deployment directory 6 | # cd deployment 7 | # ./build-s3-dist.sh source-bucket-base-name solution-name version-code [--no-suffix] 8 | # 9 | # Parameters: 10 | # - source-bucket-base-name: Name for the S3 bucket location. If the --no-suffix flag is not present, the template will 11 | # append '-reference' and '-[region_name]' suffixes to this bucket name. 12 | # 13 | # - solution-name: name of the solution for consistency 14 | # 15 | # - version-code: version of the package 16 | 17 | [ "$DEBUG" == 'true' ] && set -x 18 | set -e 19 | 20 | # Check to see if input has been provided: 21 | if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then 22 | echo "Please provide the base source bucket name, trademark approved solution name and version where the lambda code will eventually reside." 23 | echo "For example: ./build-s3-dist.sh solutions trademarked-solution-name v1.0.0" 24 | exit 1 25 | fi 26 | 27 | # This is set by initialize-repo.sh 28 | SOLUTION_ID="SO0175" 29 | 30 | # Get reference for all important folders 31 | template_dir="$PWD" 32 | template_tmp_dir="$template_dir/tmp" 33 | template_dist_dir="$template_dir/global-s3-assets" 34 | build_dist_dir="$template_dir/regional-s3-assets" 35 | source_dir="$template_dir/../source" 36 | 37 | ###################################################### 38 | # Step 1/5: Clean 39 | ###################################################### 40 | rm -rf $template_tmp_dir 41 | mkdir -p $template_tmp_dir 42 | rm -rf $template_dist_dir 43 | mkdir -p $template_dist_dir 44 | rm -rf $build_dist_dir 45 | mkdir -p $build_dist_dir 46 | rm -rf $source_dir/backend/packaged 47 | rm -rf $source_dir/backend/.chalice/deployments 48 | rm -rf $source_dir/client/node_modules 49 | rm -rf $source_dir/client/build 50 | rm -rf $source_dir/client/public/weights 51 | mkdir -p $source_dir/client/public/weights 52 | 53 | ###################################################### 54 | # Step 2/5: Build backend (Chalice) 55 | ###################################################### 56 | cd $source_dir/backend || exit 57 | # Copy the cognito CFN template to the dist dir 58 | cp cognito.yaml $template_dist_dir/cognito.template 59 | # Build 60 | python -m venv /tmp/venv 61 | . /tmp/venv/bin/activate 62 | pip install -r requirements.txt 63 | chalice package --merge-template resources.yaml $template_tmp_dir 64 | deactivate 65 | cd $template_tmp_dir || exit 66 | # Copy the Lambda function to the dist dir 67 | cp deployment.zip $build_dist_dir/deployment.zip 68 | # Append description 69 | echo 'Description: Liveness Detection Framework %%VERSION%% - Backend template' >> sam.yaml 70 | # Copy the backend CFN template to the dist dir 71 | cp sam.yaml $template_dist_dir/backend.template 72 | 73 | ###################################################### 74 | # Step 3/5: Build client (React) 75 | ###################################################### 76 | cd $source_dir/client || exit 77 | npm ci 78 | # Download ML models 79 | curl -o public/weights/tiny_face_detector_model-shard1.shard -kL https://github.com/justadudewhohacks/face-api.js/blob/a86f011d72124e5fb93e59d5c4ab98f699dd5c9c/weights/tiny_face_detector_model-shard1?raw=true 80 | echo 'f3020debaf078347b5caaff4bf6dce2f379d20bc *public/weights/tiny_face_detector_model-shard1.shard' | shasum -c 81 | curl -o public/weights/tiny_face_detector_model-weights_manifest.json -kL https://github.com/justadudewhohacks/face-api.js/blob/a86f011d72124e5fb93e59d5c4ab98f699dd5c9c/weights/tiny_face_detector_model-weights_manifest.json?raw=true 82 | echo '1f9da0ddb847fcd512cb0511f6d6c90985d011e6 *public/weights/tiny_face_detector_model-weights_manifest.json' | shasum -c 83 | curl -o public/weights/face_landmark_68_model-shard1.shard -kL https://github.com/justadudewhohacks/face-api.js/blob/a86f011d72124e5fb93e59d5c4ab98f699dd5c9c/weights/face_landmark_68_model-shard1?raw=true 84 | echo 'e8b453a3ce2a66e6fa070d4e30cd4e91c911964b *public/weights/face_landmark_68_model-shard1.shard' | shasum -c 85 | curl -o public/weights/face_landmark_68_model-weights_manifest.json -kL https://github.com/justadudewhohacks/face-api.js/blob/a86f011d72124e5fb93e59d5c4ab98f699dd5c9c/weights/face_landmark_68_model-weights_manifest.json?raw=true 86 | echo 'a981c7adfc6366e7b51b6c83b3bb84961a9a4b15 *public/weights/face_landmark_68_model-weights_manifest.json' | shasum -c 87 | 88 | # Replace model references 89 | perl -i -pe 's/tiny_face_detector_model-shard1/tiny_face_detector_model-shard1.shard/g' public/weights/tiny_face_detector_model-weights_manifest.json 90 | perl -i -pe 's/face_landmark_68_model-shard1/face_landmark_68_model-shard1.shard/g' public/weights/face_landmark_68_model-weights_manifest.json 91 | # Build 92 | npm run build 93 | # Zip web client assets into a single file 94 | cd build || exit 95 | zip -r client-build.zip . 96 | # Copy web client assets to the dist dir 97 | cp client-build.zip $build_dist_dir/ 98 | # Copy the template to the dist dir 99 | cd .. 100 | cp template-one-click.yaml $template_dist_dir/client.template 101 | 102 | ###################################################### 103 | # Step 4/5: Copy and rename templates 104 | ###################################################### 105 | cp $template_dir/*.yaml $template_dist_dir/ 106 | cd $template_dist_dir || exit 107 | # Rename all *.yaml to *.template 108 | for f in *.yaml; do 109 | mv -- "$f" "${f%.yaml}.template" 110 | done 111 | cd .. 112 | 113 | ###################################################### 114 | # Step 5/5: Replacements in templates 115 | ###################################################### 116 | # Bucket suffixes 117 | if [[ -z "$4" ]]; then suffix_ref=', "reference"'; else suffix_ref=''; fi 118 | if [[ -z "$4" ]]; then suffix_region=', !Ref AWS::Region'; else suffix_region=''; fi 119 | 120 | declare -a replacements=( \ 121 | "s/%%SUFFIX_REF%%/$suffix_ref/g" \ 122 | "s/%%SUFFIX_REGION%%/$suffix_region/g" \ 123 | "s/%%SOLUTION_ID%%/$SOLUTION_ID/g" \ 124 | "s/%%BUCKET_NAME%%/$1/g" \ 125 | "s/%%SOLUTION_NAME%%/$2/g" \ 126 | "s/%%VERSION%%/$3/g" \ 127 | "s/%%CLIENT_BUILD_KEY%%/client-build.zip/g" \ 128 | "s/\.\/deployment\.zip/{Bucket: !Ref LambdaCodeUriBucket, Key: !Ref LambdaCodeUriKey}/g" \ 129 | "s/'%%REF_COGNITO_USER_POOL_ARN%%'/!Ref CognitoUserPoolArn/g" \ 130 | "s/- LivenessUserPool:.*$/- LivenessUserPool: \[\]/g" \ 131 | ) 132 | for replacement in "${replacements[@]}" 133 | do 134 | if [[ "$OSTYPE" == "darwin"* ]]; then 135 | sed -i '' -e "$replacement" "$template_dist_dir"/*.template 136 | else 137 | sed -i -e "$replacement" "$template_dist_dir"/*.template 138 | fi 139 | done 140 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/pose.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/assets/nose.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /source/client/src/liveness/components/lottie/success.json: -------------------------------------------------------------------------------- 1 | {"v":"5.6.5","fr":29.9700012207031,"ip":0,"op":20.0000008146167,"w":300,"h":300,"nm":"Success","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Check","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[153.5,147.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[136,136,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-13.486,17.672]],"o":[[17.5,20.5],[0,0],[0,0]],"v":[[-49,-4],[-14,37],[44,-39]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":7.00000028511585,"s":[0]}],"ix":1,"x":"var $bm_rt;\nvar p = 0.8;\nvar a = 50;\nvar s = 1.70158;\nfunction easeandwizz_inOutQuad(t, b, c, d) {\n if ((t /= d / 2) < 1)\n return $bm_sum($bm_mul($bm_mul($bm_div(c, 2), t), t), b);\n return $bm_sum($bm_mul($bm_div($bm_neg(c), 2), $bm_sub($bm_mul(--t, $bm_sub(t, 2)), 1)), b);\n}\nfunction easeAndWizz() {\n var t, d, sX, eX, sY, eY, sZ, eZ, val1, val2, val2, val3;\n var n = 0;\n if (numKeys > 0) {\n n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n }\n if (n > 1 && n < numKeys - 1) {\n return null;\n }\n try {\n var key1 = key(n);\n var key2 = key($bm_sum(n, 1));\n } catch (e) {\n return null;\n }\n var dim = 1;\n try {\n key(1)[1].length;\n dim = 2;\n key(1)[2].length;\n dim = 3;\n } catch (e) {\n }\n t = $bm_sub(time, key1.time);\n d = $bm_sub(key2.time, key1.time);\n sX = key1[0];\n eX = $bm_sub(key2[0], key1[0]);\n if (dim >= 2) {\n sY = key1[1];\n eY = $bm_sub(key2[1], key1[1]);\n if (dim >= 3) {\n sZ = key1[2];\n eZ = $bm_sub(key2[2], key1[2]);\n }\n }\n if (time < key1.time || time > key2.time) {\n return value;\n } else {\n val1 = easeandwizz_inOutQuad(t, sX, eX, d, a, p, s);\n switch (dim) {\n case 1:\n return val1;\n break;\n case 2:\n val2 = easeandwizz_inOutQuad(t, sY, eY, d, a, p, s);\n return [\n val1,\n val2\n ];\n break;\n case 3:\n val2 = easeandwizz_inOutQuad(t, sY, eY, d, a, p, s);\n val3 = easeandwizz_inOutQuad(t, sZ, eZ, d, a, p, s);\n return [\n val1,\n val2,\n val3\n ];\n break;\n default:\n return null;\n }\n }\n}\n$bm_rt = easeAndWizz() || value;"},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[0]},{"t":14.0000005702317,"s":[100]}],"ix":2,"x":"var $bm_rt;\nvar p = 0.8;\nvar a = 50;\nvar s = 1.70158;\nfunction easeandwizz_inOutQuad(t, b, c, d) {\n if ((t /= d / 2) < 1)\n return $bm_sum($bm_mul($bm_mul($bm_div(c, 2), t), t), b);\n return $bm_sum($bm_mul($bm_div($bm_neg(c), 2), $bm_sub($bm_mul(--t, $bm_sub(t, 2)), 1)), b);\n}\nfunction easeAndWizz() {\n var t, d, sX, eX, sY, eY, sZ, eZ, val1, val2, val2, val3;\n var n = 0;\n if (numKeys > 0) {\n n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n }\n if (n > 1 && n < numKeys - 1) {\n return null;\n }\n try {\n var key1 = key(n);\n var key2 = key($bm_sum(n, 1));\n } catch (e) {\n return null;\n }\n var dim = 1;\n try {\n key(1)[1].length;\n dim = 2;\n key(1)[2].length;\n dim = 3;\n } catch (e) {\n }\n t = $bm_sub(time, key1.time);\n d = $bm_sub(key2.time, key1.time);\n sX = key1[0];\n eX = $bm_sub(key2[0], key1[0]);\n if (dim >= 2) {\n sY = key1[1];\n eY = $bm_sub(key2[1], key1[1]);\n if (dim >= 3) {\n sZ = key1[2];\n eZ = $bm_sub(key2[2], key1[2]);\n }\n }\n if (time < key1.time || time > key2.time) {\n return value;\n } else {\n val1 = easeandwizz_inOutQuad(t, sX, eX, d, a, p, s);\n switch (dim) {\n case 1:\n return val1;\n break;\n case 2:\n val2 = easeandwizz_inOutQuad(t, sY, eY, d, a, p, s);\n return [\n val1,\n val2\n ];\n break;\n case 3:\n val2 = easeandwizz_inOutQuad(t, sY, eY, d, a, p, s);\n val3 = easeandwizz_inOutQuad(t, sZ, eZ, d, a, p, s);\n return [\n val1,\n val2,\n val3\n ];\n break;\n default:\n return null;\n }\n }\n}\n$bm_rt = easeAndWizz() || value;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":121.000004928431,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[150,150,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[10,10,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":3,"s":[5,5,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":9,"s":[110,110,100]},{"t":12.00000048877,"s":[100,100,100]}],"ix":6,"x":"var $bm_rt;\nvar p = 0.8;\nvar a = 50;\nvar s = 1.70158;\nfunction easeandwizz_inOutQuad(t, b, c, d) {\n if ((t /= d / 2) < 1)\n return $bm_sum($bm_mul($bm_mul($bm_div(c, 2), t), t), b);\n return $bm_sum($bm_mul($bm_div($bm_neg(c), 2), $bm_sub($bm_mul(--t, $bm_sub(t, 2)), 1)), b);\n}\nfunction easeAndWizz() {\n var t, d, sX, eX, sY, eY, sZ, eZ, val1, val2, val2, val3;\n var n = 0;\n if (numKeys > 0) {\n n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n }\n if (n > 1 && n < numKeys - 1) {\n return null;\n }\n try {\n var key1 = key(n);\n var key2 = key($bm_sum(n, 1));\n } catch (e) {\n return null;\n }\n var dim = 1;\n try {\n key(1)[1].length;\n dim = 2;\n key(1)[2].length;\n dim = 3;\n } catch (e) {\n }\n t = $bm_sub(time, key1.time);\n d = $bm_sub(key2.time, key1.time);\n sX = key1[0];\n eX = $bm_sub(key2[0], key1[0]);\n if (dim >= 2) {\n sY = key1[1];\n eY = $bm_sub(key2[1], key1[1]);\n if (dim >= 3) {\n sZ = key1[2];\n eZ = $bm_sub(key2[2], key1[2]);\n }\n }\n if (time < key1.time || time > key2.time) {\n return value;\n } else {\n val1 = easeandwizz_inOutQuad(t, sX, eX, d, a, p, s);\n switch (dim) {\n case 1:\n return val1;\n break;\n case 2:\n val2 = easeandwizz_inOutQuad(t, sY, eY, d, a, p, s);\n return [\n val1,\n val2\n ];\n break;\n case 3:\n val2 = easeandwizz_inOutQuad(t, sY, eY, d, a, p, s);\n val3 = easeandwizz_inOutQuad(t, sZ, eZ, d, a, p, s);\n return [\n val1,\n val2,\n val3\n ];\n break;\n default:\n return null;\n }\n }\n}\n$bm_rt = easeAndWizz() || value;"}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[270.547,270.547],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.086274512112,0.749019622803,0.623529434204,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-0.727,-0.727],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":121.000004928431,"st":0,"bm":0}],"markers":[]} -------------------------------------------------------------------------------- /source/client/src/liveness/nose/NoseChallengeProcessor.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as faceapi from "face-api.js"; 7 | import { NoseChallengeParams } from "./NoseChallengeParams"; 8 | import { OverlayCanvasDrawer } from "./OverlayCanvasDrawer"; 9 | import { StateManager, StateManagerOutput } from "./StateManager"; 10 | import { APIUtils, ChallengeMetadata } from "../utils/APIUtils"; 11 | import { ConfigUtils } from "../utils/ConfigUtils"; 12 | import { MediaUtils } from "../utils/MediaUtils"; 13 | import { LogUtils } from "../utils/LogUtils"; 14 | 15 | export class NoseChallengeProcessor { 16 | private readonly challengeId: string; 17 | private readonly challengeToken: string; 18 | private readonly localEndCallback: (success: boolean) => void; 19 | private readonly uploadEndCallback: () => void; 20 | private readonly helpMessageCallback: (helpMessage: string | undefined) => void; 21 | private readonly helpAnimationCallback: (helpAnimationNumber: number | undefined) => void; 22 | private readonly overlayCanvasDrawer: OverlayCanvasDrawer; 23 | private readonly stateManager: StateManager; 24 | private readonly cameraVideoElement: HTMLVideoElement; 25 | private readonly overlayCanvasElement: HTMLCanvasElement; 26 | private readonly invisibleCanvasElement: HTMLCanvasElement; 27 | 28 | private lastHelpMessage: string | undefined; 29 | private lastHelpAnimationNumber: number | undefined; 30 | private uploadPromises: Promise[]; 31 | 32 | private static modelPromises: Promise[] = []; 33 | 34 | constructor( 35 | challengeMetadata: ChallengeMetadata, 36 | cameraVideoElementId: string, 37 | overlayCanvasElementId: string, 38 | localEndCallback: (localSuccess: boolean) => void, 39 | uploadEndCallback: () => void, 40 | helpMessageCallback: (helpMessage: string | undefined) => void, 41 | helpAnimationCallback: (helpAnimationNumber: number | undefined) => void 42 | ) { 43 | this.challengeId = challengeMetadata.id; 44 | this.challengeToken = challengeMetadata.token; 45 | this.localEndCallback = localEndCallback; 46 | this.uploadEndCallback = uploadEndCallback; 47 | this.helpMessageCallback = helpMessageCallback; 48 | this.helpAnimationCallback = helpAnimationCallback; 49 | this.stateManager = new StateManager(challengeMetadata.params as NoseChallengeParams); 50 | this.cameraVideoElement = document.getElementById(cameraVideoElementId) as HTMLVideoElement; 51 | if (!this.cameraVideoElement) { 52 | throw Error("Camera video element not found"); 53 | } 54 | this.cameraVideoElement.srcObject = MediaUtils.getMediaStreamInfo().mediaStream; 55 | 56 | this.overlayCanvasElement = document.getElementById(overlayCanvasElementId) as HTMLCanvasElement; 57 | if (!this.overlayCanvasElement) { 58 | throw Error("Overlay canvas element not found"); 59 | } 60 | this.overlayCanvasDrawer = new OverlayCanvasDrawer(this.overlayCanvasElement); 61 | 62 | this.invisibleCanvasElement = document.createElement("canvas"); 63 | 64 | this.uploadPromises = []; 65 | } 66 | 67 | static loadModels(): Promise { 68 | if (NoseChallengeProcessor.modelPromises.length === 0) { 69 | const url = "/weights/"; 70 | NoseChallengeProcessor.modelPromises.push(this.loadFaceDetectionModel(url)); 71 | NoseChallengeProcessor.modelPromises.push(this.loadLandmarkModel(url)); 72 | } 73 | return Promise.all(NoseChallengeProcessor.modelPromises); 74 | } 75 | 76 | private static loadFaceDetectionModel(url: string): Promise { 77 | const promise = faceapi.nets.tinyFaceDetector.load(url); 78 | promise.then(() => { 79 | LogUtils.info("tinyFaceDetector model loaded"); 80 | }); 81 | return promise; 82 | } 83 | 84 | private static loadLandmarkModel(url: string): Promise { 85 | const promise = faceapi.nets.faceLandmark68Net.load(url); 86 | promise.then(() => { 87 | LogUtils.info("faceLandmark68Net model loaded"); 88 | }); 89 | return promise; 90 | } 91 | 92 | public start() { 93 | this.cameraVideoElement.addEventListener("loadedmetadata", () => { 94 | this.process(); 95 | }); 96 | } 97 | 98 | private process(): any { 99 | LogUtils.debug("video event handler"); 100 | if (this.cameraVideoElement.paused || this.cameraVideoElement.ended) { 101 | LogUtils.debug("video paused or ended"); 102 | return setTimeout(() => this.process(), 10); 103 | } 104 | const options = new faceapi.TinyFaceDetectorOptions(); 105 | faceapi 106 | .detectAllFaces(this.cameraVideoElement, options) 107 | .withFaceLandmarks(false) 108 | .then((result: faceapi.WithFaceLandmarks<{ detection: faceapi.FaceDetection }, faceapi.FaceLandmarks68>[]) => { 109 | if (result) { 110 | this.processDetectionResults(result); 111 | } else { 112 | setTimeout(() => this.process()); 113 | } 114 | return result; 115 | }); 116 | } 117 | 118 | private processDetectionResults( 119 | results: faceapi.WithFaceLandmarks<{ detection: faceapi.FaceDetection }, faceapi.FaceLandmarks68>[] 120 | ) { 121 | const dims = faceapi.matchDimensions(this.overlayCanvasElement, this.cameraVideoElement); 122 | const resizedResults = faceapi.resizeResults(results, dims); 123 | const stateManagerOutput: StateManagerOutput = this.stateManager.process(results); 124 | 125 | if (ConfigUtils.getConfigBooleanValue("DRAW_DETECTIONS")) { 126 | faceapi.draw.drawDetections(this.overlayCanvasElement, resizedResults); 127 | faceapi.draw.drawFaceLandmarks(this.overlayCanvasElement, resizedResults); 128 | } 129 | 130 | if (stateManagerOutput.drawOptions) { 131 | this.overlayCanvasDrawer.draw(stateManagerOutput.drawOptions); 132 | } 133 | 134 | if (stateManagerOutput.helpMessage !== this.lastHelpMessage) { 135 | LogUtils.debug(`help message change: from='${this.lastHelpMessage}' to='${stateManagerOutput.helpMessage}'`); 136 | this.helpMessageCallback(stateManagerOutput.helpMessage); 137 | } 138 | this.lastHelpMessage = stateManagerOutput.helpMessage; 139 | 140 | if (stateManagerOutput.helpAnimationNumber !== this.lastHelpAnimationNumber) { 141 | LogUtils.debug( 142 | `help animation change: from=${this.lastHelpAnimationNumber} to=${stateManagerOutput.helpAnimationNumber}` 143 | ); 144 | this.helpAnimationCallback(stateManagerOutput.helpAnimationNumber); 145 | } 146 | this.lastHelpAnimationNumber = stateManagerOutput.helpAnimationNumber; 147 | 148 | if (stateManagerOutput.shouldSaveFrame) { 149 | LogUtils.debug("should save frame"); 150 | this.uploadPromises.push(this.uploadFrame()); 151 | } 152 | 153 | // if challenge completed locally 154 | if (stateManagerOutput.end) { 155 | const localSuccess = stateManagerOutput.success as boolean; 156 | LogUtils.info("local challenge result: %s", localSuccess); 157 | this.localEndCallback(localSuccess); 158 | Promise.all(this.uploadPromises).then(() => { 159 | this.uploadEndCallback(); 160 | }); 161 | } 162 | // if not completed, schedule next frame capture 163 | else { 164 | const delay = 1000 / parseInt(ConfigUtils.getConfig().MAX_FPS); 165 | setTimeout(() => this.process(), delay); 166 | } 167 | } 168 | 169 | private uploadFrame(): Promise { 170 | const invisibleCanvasContext = this.invisibleCanvasElement.getContext("2d"); 171 | if (invisibleCanvasContext === null) { 172 | throw Error("Error getting invisible canvas context"); 173 | } 174 | this.invisibleCanvasElement.width = this.cameraVideoElement.videoWidth; 175 | this.invisibleCanvasElement.height = this.cameraVideoElement.videoHeight; 176 | invisibleCanvasContext.drawImage( 177 | this.cameraVideoElement, 178 | 0, 179 | 0, 180 | this.cameraVideoElement.videoWidth, 181 | this.cameraVideoElement.videoHeight 182 | ); 183 | 184 | if (ConfigUtils.getConfigBooleanValue("FLIP_VIDEO")) { 185 | invisibleCanvasContext.scale(-1, 1); 186 | } 187 | 188 | const canvas = this.invisibleCanvasElement; 189 | return new Promise((resolve: () => void, reject: (reason: any) => void) => { 190 | const image = canvas.toDataURL("image/jpeg", ConfigUtils.getConfig().IMAGE_JPG_QUALITY); 191 | APIUtils.putChallengeFrame( 192 | this.challengeId, 193 | this.challengeToken, 194 | image.substr(image.indexOf(",") + 1), 195 | Date.now() 196 | ) 197 | .then(resolve) 198 | .catch(reject); 199 | }); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /source/client/src/liveness/pose/PoseChallenge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from "react"; 7 | import "./PoseChallenge.css"; 8 | import SpinnerMessage from "../components/SpinnerMessage"; 9 | import { CanvasUtils } from "../utils/CanvasUtils"; 10 | import { APIUtils, ChallengeMetadata } from "../utils/APIUtils"; 11 | import { ConfigUtils } from "../utils/ConfigUtils"; 12 | import { MediaUtils } from "../utils/MediaUtils"; 13 | import { FacePose } from "./FacePose"; 14 | 15 | const STEP_1 = "InstructionsStep"; 16 | const STEP_2 = "PoseStep"; 17 | const STEP_3 = "CheckStep"; 18 | 19 | type Props = { 20 | challengeMetadata: ChallengeMetadata; 21 | onLocalEnd: (localSuccess: boolean) => void; 22 | onError: (error: Error) => void; 23 | }; 24 | 25 | type State = { 26 | uploading: boolean; 27 | stepName: string; 28 | }; 29 | 30 | export default class PoseChallenge extends React.Component { 31 | constructor(props: Props | Readonly) { 32 | super(props); 33 | this.state = { 34 | uploading: false, 35 | stepName: STEP_1 36 | }; 37 | this.startChallenge = this.startChallenge.bind(this); 38 | this.takePhoto = this.takePhoto.bind(this); 39 | this.endChallenge = this.endChallenge.bind(this); 40 | } 41 | 42 | startChallenge() { 43 | this.setState({ 44 | stepName: STEP_2 45 | }); 46 | } 47 | 48 | takePhoto() { 49 | const videoWidth = MediaUtils.getMediaStreamInfo().actualWidth; 50 | const videoHeight = MediaUtils.getMediaStreamInfo().actualHeight; 51 | const flip = ConfigUtils.getConfigBooleanValue("FLIP_VIDEO"); 52 | CanvasUtils.takePhoto("video-camera", "canvas-invisible", videoWidth, videoHeight, flip); 53 | CanvasUtils.drawScaledCanvasInCanvas("canvas-invisible", "canvas-photo-check"); 54 | this.setState({ 55 | stepName: STEP_3 56 | }); 57 | } 58 | 59 | endChallenge() { 60 | const base64Photo = CanvasUtils.getPhotoFromCanvas("canvas-invisible", ConfigUtils.getConfig().IMAGE_JPG_QUALITY); 61 | this.setState({ 62 | uploading: true 63 | }); 64 | const self = this; 65 | APIUtils.putChallengeFrame( 66 | this.props.challengeMetadata.id, 67 | this.props.challengeMetadata.token, 68 | base64Photo, 69 | Date.now() 70 | ) 71 | .then(() => this.props.onLocalEnd(true)) 72 | .catch(error => this.props.onError(error)) 73 | .finally(() => { 74 | self.setState({ 75 | uploading: false 76 | }); 77 | }); 78 | } 79 | 80 | componentDidMount() { 81 | CanvasUtils.setVideoElementSrc("video-camera", MediaUtils.getMediaStreamInfo().mediaStream); 82 | // @ts-ignore 83 | const pose = this.props.challengeMetadata.params.pose; 84 | for (const canvasElementId of ["canvas-pose-big", "canvas-pose-small", "canvas-pose-check"]) { 85 | new FacePose(pose.eyes, pose.mouth).draw(canvasElementId); 86 | } 87 | } 88 | 89 | render() { 90 | const videoWidth = MediaUtils.getMediaStreamInfo().actualWidth; 91 | const videoHeight = MediaUtils.getMediaStreamInfo().actualHeight; 92 | const shouldRotate = ConfigUtils.getConfigBooleanValue("FLIP_VIDEO"); 93 | 94 | return ( 95 |
96 | {!this.state.uploading && ( 97 |
98 |
99 | {/* */} 114 | 115 |

Get ready to copy the pose

116 |

In the next step, copy the facial expression shown below.

117 |
118 | 119 |
120 | 123 |
124 |
125 |

Copy the pose

126 |

Avoid rotating your face and make sure it is well-illuminated.

127 |
128 | 129 |
154 | 173 |
174 |
175 |

Do these pictures match?

176 |

Check if your pose is as similar as possible.

177 |
178 | 179 | 180 |
181 |
182 | 189 | 192 |
193 |
194 |
195 | )} 196 | {this.state.uploading && } 197 |
198 | ); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /source/client/src/liveness/nose/States.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as faceapi from "face-api.js"; 7 | import { NoseChallengeParams } from "./NoseChallengeParams"; 8 | import { DrawColors, DrawOptions } from "./OverlayCanvasDrawer"; 9 | import { ConfigUtils } from "../utils/ConfigUtils"; 10 | import { LogUtils } from "../utils/LogUtils"; 11 | 12 | export interface StateOutput { 13 | readonly nextState?: State; 14 | readonly drawOptions?: DrawOptions; 15 | readonly helpMessage?: string; 16 | readonly helpAnimationNumber?: number; 17 | } 18 | 19 | export abstract class State { 20 | constructor(readonly noseChallengeParams: NoseChallengeParams) {} 21 | 22 | process( 23 | faces: faceapi.WithFaceLandmarks<{ detection: faceapi.FaceDetection }, faceapi.FaceLandmarks68>[] 24 | ): StateOutput { 25 | return {}; 26 | } 27 | 28 | getMaximumDurationInSeconds(): number { 29 | return -1; 30 | } 31 | 32 | protected isFaceBoxInsideFaceArea(faceBox: faceapi.Box, addTolerance = true) { 33 | const tolerance: number = addTolerance ? parseInt(ConfigUtils.getConfig().FACE_AREA_TOLERANCE_PERCENT) / 100 : 0; 34 | return ( 35 | faceBox.x >= this.noseChallengeParams.areaLeft * (1 - tolerance) && 36 | faceBox.y >= this.noseChallengeParams.areaTop * (1 - tolerance) && 37 | faceBox.x + faceBox.width <= 38 | this.noseChallengeParams.areaLeft + this.noseChallengeParams.areaWidth * (1 + tolerance) && 39 | faceBox.y + faceBox.height <= 40 | this.noseChallengeParams.areaTop + this.noseChallengeParams.areaHeight * (1 + tolerance) 41 | ); 42 | } 43 | 44 | protected isNoseInsideNoseArea(nose: faceapi.IPoint) { 45 | return ( 46 | nose.x >= this.noseChallengeParams.noseLeft && 47 | nose.y >= this.noseChallengeParams.noseTop && 48 | nose.x <= this.noseChallengeParams.noseLeft + this.noseChallengeParams.noseWidth && 49 | nose.y <= this.noseChallengeParams.noseTop + this.noseChallengeParams.noseHeight 50 | ); 51 | } 52 | 53 | abstract getName(): string; 54 | } 55 | 56 | export class FailState extends State { 57 | static NAME = "FailState"; 58 | 59 | getName(): string { 60 | return FailState.NAME; 61 | } 62 | } 63 | 64 | export class SuccessState extends State { 65 | static NAME = "SuccessState"; 66 | 67 | getName(): string { 68 | return SuccessState.NAME; 69 | } 70 | } 71 | 72 | export class NoseState extends State { 73 | static NAME = "NoseState"; 74 | 75 | private framesWithoutFace = 0; 76 | private landmarkIndex = parseInt(ConfigUtils.getConfig().LANDMARK_INDEX); 77 | 78 | process( 79 | faces: faceapi.WithFaceLandmarks<{ detection: faceapi.FaceDetection }, faceapi.FaceLandmarks68>[] 80 | ): StateOutput { 81 | let nextState: State = this; 82 | if (faces.length === 1) { 83 | if (this.isFaceBoxInsideFaceArea(faces[0].detection.box)) { 84 | if (this.isNoseInsideNoseArea(faces[0].landmarks.positions[this.landmarkIndex])) { 85 | nextState = new SuccessState(this.noseChallengeParams); 86 | } 87 | } else { 88 | LogUtils.info( 89 | `NoseState fail: isFaceBoxInsideFaceArea=${this.isFaceBoxInsideFaceArea(faces[0].detection.box)}` 90 | ); 91 | nextState = new FailState(this.noseChallengeParams); 92 | } 93 | } else { 94 | if ( 95 | faces.length !== 0 || 96 | ++this.framesWithoutFace > parseInt(ConfigUtils.getConfig().STATE_NOSE_MAX_FRAMES_WITHOUT_FACE) 97 | ) { 98 | LogUtils.info(`NoseState fail: #faces=${faces.length} framesWithoutFace=${this.framesWithoutFace}`); 99 | nextState = new FailState(this.noseChallengeParams); 100 | } else { 101 | LogUtils.debug(`no face detected. Skipping frame...`); 102 | } 103 | } 104 | const drawOptions: DrawOptions = { 105 | faceDrawBoxOptions: { 106 | boxColor: DrawColors.GREEN, 107 | boxHeight: this.noseChallengeParams.areaHeight, 108 | boxLeft: this.noseChallengeParams.areaLeft, 109 | boxTop: this.noseChallengeParams.areaTop, 110 | boxWidth: this.noseChallengeParams.areaWidth 111 | }, 112 | noseDrawBoxOptions: { 113 | boxColor: DrawColors.YELLOW, 114 | boxHeight: this.noseChallengeParams.noseHeight, 115 | boxLeft: this.noseChallengeParams.noseLeft, 116 | boxTop: this.noseChallengeParams.noseTop, 117 | boxWidth: this.noseChallengeParams.noseWidth 118 | } 119 | }; 120 | return { 121 | nextState: nextState, 122 | drawOptions: drawOptions, 123 | helpMessage: "Slowly move the tip of your nose inside the yellow area", 124 | helpAnimationNumber: 2 125 | }; 126 | } 127 | 128 | getMaximumDurationInSeconds(): number { 129 | return parseInt(ConfigUtils.getConfig().STATE_NOSE_DURATION_IN_SECONDS); 130 | } 131 | 132 | getName(): string { 133 | return NoseState.NAME; 134 | } 135 | } 136 | 137 | export class AreaState extends State { 138 | static NAME = "AreaState"; 139 | 140 | private framesWithoutFace = 0; 141 | 142 | process( 143 | faces: faceapi.WithFaceLandmarks<{ detection: faceapi.FaceDetection }, faceapi.FaceLandmarks68>[] 144 | ): StateOutput { 145 | let nextState: State = this; 146 | let boxColor = DrawColors.RED; 147 | if (faces.length === 1) { 148 | if (this.isFaceBoxInsideFaceArea(faces[0].detection.box, false)) { 149 | boxColor = DrawColors.GREEN; 150 | nextState = new NoseState(this.noseChallengeParams); 151 | } 152 | } else { 153 | if ( 154 | faces.length !== 0 || 155 | ++this.framesWithoutFace > parseInt(ConfigUtils.getConfig().STATE_AREA_MAX_FRAMES_WITHOUT_FACE) 156 | ) { 157 | LogUtils.info(`AreaState fail: #faces=${faces.length} framesWithoutFace=${this.framesWithoutFace}`); 158 | nextState = new FailState(this.noseChallengeParams); 159 | } else { 160 | LogUtils.debug(`no face detected. Skipping frame...`); 161 | } 162 | } 163 | const drawOptions: DrawOptions = { 164 | faceDrawBoxOptions: { 165 | boxColor: boxColor, 166 | boxHeight: this.noseChallengeParams.areaHeight, 167 | boxLeft: this.noseChallengeParams.areaLeft, 168 | boxTop: this.noseChallengeParams.areaTop, 169 | boxWidth: this.noseChallengeParams.areaWidth 170 | } 171 | }; 172 | return { 173 | nextState: nextState, 174 | drawOptions: drawOptions, 175 | helpMessage: "Center your face inside the area", 176 | helpAnimationNumber: 1 177 | }; 178 | } 179 | 180 | getMaximumDurationInSeconds(): number { 181 | return parseInt(ConfigUtils.getConfig().STATE_AREA_DURATION_IN_SECONDS); 182 | } 183 | 184 | getName(): string { 185 | return AreaState.NAME; 186 | } 187 | } 188 | 189 | export class FaceState extends State { 190 | static NAME = "FaceState"; 191 | 192 | private numFramesCorrect = 0; 193 | 194 | protected isFaceBoxAreBiggerThanMin(faceBox: faceapi.Box) { 195 | const totalArea = this.noseChallengeParams.areaWidth * this.noseChallengeParams.areaHeight; 196 | const faceAreaPercent = (faceBox.area * 100) / totalArea; 197 | const faceAreaTolerance = parseInt(ConfigUtils.getConfig().FACE_AREA_TOLERANCE_PERCENT); 198 | const minFaceAreaPercent = parseInt(ConfigUtils.getConfig().MIN_FACE_AREA_PERCENT); 199 | const isBigger = faceAreaPercent + faceAreaTolerance >= minFaceAreaPercent; 200 | LogUtils.debug( 201 | `isFaceBoxAreBiggerThanMin: ${isBigger} Face area: ${faceAreaPercent}% Minimum: ${minFaceAreaPercent - 202 | faceAreaTolerance}%` 203 | ); 204 | return isBigger; 205 | } 206 | 207 | process( 208 | faces: faceapi.WithFaceLandmarks<{ detection: faceapi.FaceDetection }, faceapi.FaceLandmarks68>[] 209 | ): StateOutput { 210 | let nextState: State = this; 211 | let helpMessage = undefined; 212 | switch (faces.length) { 213 | case 0: 214 | this.numFramesCorrect = 0; 215 | helpMessage = "No face detected. Look at the camera."; 216 | break; 217 | case 1: 218 | if (this.isFaceBoxAreBiggerThanMin(faces[0].detection.box)) { 219 | this.numFramesCorrect++; 220 | if (this.numFramesCorrect >= parseInt(ConfigUtils.getConfig().MIN_FRAMES_FACE_STATE)) { 221 | nextState = new AreaState(this.noseChallengeParams); 222 | } 223 | } else { 224 | helpMessage = "You're too far. Come closer."; 225 | } 226 | break; 227 | default: 228 | this.numFramesCorrect = 0; 229 | helpMessage = "More than one face detected. Should be one."; 230 | } 231 | const drawOptions: DrawOptions = { 232 | faceDrawBoxOptions: { 233 | boxColor: DrawColors.RED, 234 | boxHeight: this.noseChallengeParams.areaHeight, 235 | boxLeft: this.noseChallengeParams.areaLeft, 236 | boxTop: this.noseChallengeParams.areaTop, 237 | boxWidth: this.noseChallengeParams.areaWidth 238 | } 239 | }; 240 | return { 241 | nextState: nextState, 242 | drawOptions: drawOptions, 243 | helpMessage: helpMessage 244 | }; 245 | } 246 | 247 | getName(): string { 248 | return FaceState.NAME; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /source/backend/chalicelib/nose.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import logging 5 | import math 6 | import secrets 7 | 8 | import numpy as np 9 | 10 | from .framework import STATE_NEXT, STATE_CONTINUE, CHALLENGE_SUCCESS, CHALLENGE_FAIL 11 | from .framework import challenge_params, challenge_state 12 | 13 | _AREA_BOX_WIDTH_RATIO = 0.75 14 | _AREA_BOX_HEIGHT_RATIO = 0.75 15 | _AREA_BOX_ASPECT_RATIO = 0.75 16 | _AREA_BOX_TOLERANCE = 0.05 17 | _MIN_FACE_AREA_PERCENT = 40 18 | _MIN_FACE_AREA_PERCENT_TOLERANCE = 20 19 | _NOSE_BOX_SIZE = 20 20 | _NOSE_BOX_CENTER_MIN_H_DIST = 45 21 | _NOSE_BOX_CENTER_MAX_H_DIST = 75 22 | _NOSE_BOX_CENTER_MAX_V_DIST = 40 23 | _NOSE_BOX_TOLERANCE = 0.55 24 | _TRAJECTORY_ERROR_THRESHOLD = 0.02 25 | _HISTOGRAM_BINS = 3 26 | _MIN_DIST = 0.10 27 | _ROTATION_THRESHOLD = 5.0 28 | _MIN_DIST_FACTOR_ROTATED = 0.75 29 | _MIN_DIST_FACTOR_NOT_ROTATED = 1.5 30 | 31 | _log = logging.getLogger('liveness-backend') 32 | 33 | 34 | @challenge_params(challenge_type='NOSE') 35 | def nose_challenge_params(client_metadata): 36 | image_width = int(client_metadata['imageWidth']) 37 | image_height = int(client_metadata['imageHeight']) 38 | area_x, area_y, area_w, area_h = _get_area_box(image_width, image_height) 39 | nose_x, nose_y, nose_w, nose_h = _get_nose_box(image_width, image_height) 40 | params = dict() 41 | params['imageWidth'] = image_width 42 | params['imageHeight'] = image_height 43 | params['areaLeft'] = int(area_x) 44 | params['areaTop'] = int(area_y) 45 | params['areaWidth'] = int(area_w) 46 | params['areaHeight'] = int(area_h) 47 | params['minFaceAreaPercent'] = _MIN_FACE_AREA_PERCENT 48 | params['noseLeft'] = int(nose_x) 49 | params['noseTop'] = int(nose_y) 50 | params['noseWidth'] = int(nose_w) 51 | params['noseHeight'] = int(nose_h) 52 | return params 53 | 54 | 55 | @challenge_state(challenge_type='NOSE', first=True, next_state='area_state') 56 | def face_state(_params, frame, _context): 57 | if len(frame['rekMetadata']) == 1: 58 | return STATE_NEXT 59 | return STATE_CONTINUE 60 | 61 | 62 | @challenge_state(challenge_type='NOSE', next_state='nose_state') 63 | def area_state(params, frame, _context): 64 | image_width = params['imageWidth'] 65 | image_height = params['imageHeight'] 66 | 67 | # Validating if face is inside area 68 | area_box = (params['areaLeft'], params['areaTop'], 69 | params['areaWidth'], params['areaHeight']) 70 | rek_metadata = frame['rekMetadata'][0] 71 | rek_face_box = [ 72 | image_width * rek_metadata['BoundingBox']['Left'], 73 | image_height * rek_metadata['BoundingBox']['Top'], 74 | image_width * rek_metadata['BoundingBox']['Width'], 75 | image_height * rek_metadata['BoundingBox']['Height'] 76 | ] 77 | inside_area_box = _is_inside_area_box(area_box, rek_face_box) 78 | _log.debug('inside_area_box: %s', inside_area_box) 79 | if not inside_area_box: 80 | return STATE_CONTINUE 81 | 82 | # Validating if face area is larger than minimal 83 | area_box_area = area_box[2] * area_box[3] 84 | rek_face_box_area = rek_face_box[2] * rek_face_box[3] 85 | rek_face_area_percent = rek_face_box_area * 100 / area_box_area 86 | gte_min_face_area = rek_face_area_percent + _MIN_FACE_AREA_PERCENT_TOLERANCE >= params['minFaceAreaPercent'] 87 | _log.debug('gte_min_face_area: %s', gte_min_face_area) 88 | if gte_min_face_area: 89 | return STATE_NEXT 90 | return STATE_CONTINUE 91 | 92 | 93 | @challenge_state(challenge_type='NOSE') 94 | def nose_state(params, frame, context): 95 | init_context(context, frame) 96 | 97 | image_width = params['imageWidth'] 98 | image_height = params['imageHeight'] 99 | 100 | # Validating if face is inside area (with tolerance) 101 | area_width_tolerance = params['areaWidth'] * _AREA_BOX_TOLERANCE 102 | area_height_tolerance = params['areaHeight'] * _AREA_BOX_TOLERANCE 103 | area_box = (params['areaLeft'] - area_width_tolerance, 104 | params['areaTop'] - area_height_tolerance, 105 | params['areaWidth'] + 2 * area_width_tolerance, 106 | params['areaHeight'] + 2 * area_height_tolerance) 107 | rek_metadata = frame['rekMetadata'][0] 108 | rek_face_box = [ 109 | image_width * rek_metadata['BoundingBox']['Left'], 110 | image_height * rek_metadata['BoundingBox']['Top'], 111 | image_width * rek_metadata['BoundingBox']['Width'], 112 | image_height * rek_metadata['BoundingBox']['Height'] 113 | ] 114 | inside_area_box = _is_inside_area_box(area_box, rek_face_box) 115 | _log.debug('inside_area_box: %s', inside_area_box) 116 | if not inside_area_box: 117 | return CHALLENGE_FAIL 118 | 119 | # Validating nose position (with tolerance) 120 | nose_width_tolerance = params['noseWidth'] * _NOSE_BOX_TOLERANCE 121 | nose_height_tolerance = params['noseHeight'] * _NOSE_BOX_TOLERANCE 122 | nose_box = (params['noseLeft'] - nose_width_tolerance, 123 | params['noseTop'] - nose_height_tolerance, 124 | params['noseWidth'] + 2 * nose_width_tolerance, 125 | params['noseHeight'] + 2 * nose_height_tolerance) 126 | rek_landmarks = rek_metadata['Landmarks'] 127 | inside_nose_box = False 128 | for landmark in rek_landmarks: 129 | if landmark['Type'] == 'nose': 130 | nose_left = image_width * landmark['X'] 131 | nose_top = image_height * landmark['Y'] 132 | context['nose_trajectory'].append((landmark['X'], landmark['Y'])) 133 | inside_nose_box = (nose_box[0] <= nose_left <= nose_box[0] + nose_box[2] and 134 | nose_box[1] <= nose_top <= nose_box[1] + nose_box[3]) 135 | _log.debug('inside_nose_box: %s', inside_nose_box) 136 | if not inside_nose_box: 137 | return STATE_CONTINUE 138 | 139 | # Validating continuous and linear nose trajectory 140 | nose_trajectory_x = [nose[0] for nose in context['nose_trajectory']] 141 | nose_trajectory_y = [nose[1] for nose in context['nose_trajectory']] 142 | # noinspection PyTupleAssignmentBalance 143 | _, residuals, _, _, _ = np.polyfit(nose_trajectory_x, nose_trajectory_y, 2, full=True) 144 | trajectory_error = math.sqrt(residuals / len(context['nose_trajectory'])) 145 | if trajectory_error > _TRAJECTORY_ERROR_THRESHOLD: 146 | _log.info('invalid_trajectory') 147 | return CHALLENGE_FAIL 148 | 149 | # Plotting landmarks from the first frame in a histogram 150 | original_landmarks_x = [image_width * landmark['X'] for landmark in context['original_landmarks']] 151 | original_landmarks_y = [image_height * landmark['Y'] for landmark in context['original_landmarks']] 152 | original_histogram, _, _ = np.histogram2d(original_landmarks_x, 153 | original_landmarks_y, 154 | bins=_HISTOGRAM_BINS) 155 | original_histogram = np.reshape(original_histogram, _HISTOGRAM_BINS ** 2) / len( 156 | original_landmarks_x) 157 | # Plotting landmarks from the last frame in a histogram 158 | current_landmarks_x = [image_width * landmark['X'] for landmark in rek_landmarks] 159 | current_landmarks_y = [image_height * landmark['Y'] for landmark in rek_landmarks] 160 | current_histogram, _, _ = np.histogram2d(current_landmarks_x, 161 | current_landmarks_y, 162 | bins=_HISTOGRAM_BINS) 163 | current_histogram = np.reshape(current_histogram, _HISTOGRAM_BINS ** 2) / len(current_landmarks_x) 164 | # Calculating the Euclidean distance between histograms 165 | dist = np.linalg.norm(original_histogram - current_histogram) 166 | # Estimating left and right rotation 167 | yaw = rek_metadata['Pose']['Yaw'] 168 | rotated_right = yaw > _ROTATION_THRESHOLD 169 | rotated_left = yaw < - _ROTATION_THRESHOLD 170 | rotated_face = rotated_left or rotated_right 171 | # Validating distance according to rotation 172 | challenge_in_the_right = params['noseLeft'] + _NOSE_BOX_SIZE / 2 > image_width / 2 173 | if (rotated_right and challenge_in_the_right) or (rotated_left and not challenge_in_the_right): 174 | min_dist = _MIN_DIST * _MIN_DIST_FACTOR_ROTATED 175 | elif not rotated_face: 176 | min_dist = _MIN_DIST * _MIN_DIST_FACTOR_NOT_ROTATED 177 | else: 178 | _log.info('invalid_rotation') 179 | return CHALLENGE_FAIL 180 | if dist > min_dist: 181 | _log.info('valid_distance') 182 | return CHALLENGE_SUCCESS 183 | _log.info('invalid_distance') 184 | return CHALLENGE_FAIL 185 | 186 | 187 | def init_context(context, frame): 188 | if 'original_landmarks' not in context: 189 | context['original_landmarks'] = frame['rekMetadata'][0]['Landmarks'] 190 | if 'nose_trajectory' not in context: 191 | context['nose_trajectory'] = [] 192 | 193 | 194 | def _get_area_box(image_width, image_height): 195 | area_height = image_height * _AREA_BOX_HEIGHT_RATIO 196 | area_width = min( 197 | image_width * _AREA_BOX_WIDTH_RATIO, 198 | area_height * _AREA_BOX_ASPECT_RATIO 199 | ) 200 | area_left = image_width / 2 - area_width / 2 201 | area_top = image_height / 2 - area_height / 2 202 | return (area_left, 203 | area_top, 204 | area_width, 205 | area_height) 206 | 207 | 208 | def _get_nose_box(image_width, image_height): 209 | width = _NOSE_BOX_SIZE 210 | height = _NOSE_BOX_SIZE 211 | multiplier = secrets.choice([1, -1]) 212 | left = image_width / 2 + ( 213 | multiplier * 214 | (_NOSE_BOX_CENTER_MIN_H_DIST + secrets.randbelow(_NOSE_BOX_CENTER_MAX_H_DIST - _NOSE_BOX_CENTER_MIN_H_DIST)) 215 | ) 216 | if multiplier == -1: 217 | left = left - width 218 | multiplier = secrets.choice([1, -1]) 219 | top = image_height / 2 + ( 220 | multiplier * 221 | secrets.randbelow(_NOSE_BOX_CENTER_MAX_V_DIST) 222 | ) 223 | if multiplier == -1: 224 | top = top - height 225 | return [left, top, width, height] 226 | 227 | 228 | def _is_inside_area_box(area_box, face_box): 229 | return (area_box[0] <= face_box[0] and area_box[1] <= face_box[1] and 230 | area_box[0] + area_box[2] >= face_box[0] + face_box[2] and 231 | area_box[1] + area_box[3] >= face_box[1] + face_box[3]) 232 | -------------------------------------------------------------------------------- /source/client/template-one-click.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | 3 | Description: Liveness Detection Framework %%VERSION%% - Client template 4 | 5 | Parameters: 6 | BuildBucket: 7 | Type: String 8 | BuildKey: 9 | Type: String 10 | EndpointURL: 11 | Type: String 12 | UserPoolId: 13 | Type: String 14 | UserPoolWebClientId: 15 | Type: String 16 | Resources: 17 | StaticWebsiteBucket: 18 | Type: AWS::S3::Bucket 19 | DeletionPolicy: Retain 20 | Properties: 21 | VersioningConfiguration: 22 | Status: Enabled 23 | PublicAccessBlockConfiguration: 24 | BlockPublicAcls: true 25 | BlockPublicPolicy: true 26 | IgnorePublicAcls: true 27 | RestrictPublicBuckets: true 28 | BucketEncryption: 29 | ServerSideEncryptionConfiguration: 30 | - ServerSideEncryptionByDefault: 31 | SSEAlgorithm: AES256 32 | LoggingConfiguration: 33 | DestinationBucketName: !Ref LoggingBucket 34 | LogFilePrefix: "s3-static-website-bucket/" 35 | LoggingBucket: 36 | Type: AWS::S3::Bucket 37 | DeletionPolicy: Retain 38 | Properties: 39 | VersioningConfiguration: 40 | Status: Enabled 41 | PublicAccessBlockConfiguration: 42 | BlockPublicAcls: true 43 | BlockPublicPolicy: true 44 | IgnorePublicAcls: true 45 | RestrictPublicBuckets: true 46 | AccessControl: LogDeliveryWrite 47 | BucketEncryption: 48 | ServerSideEncryptionConfiguration: 49 | - ServerSideEncryptionByDefault: 50 | SSEAlgorithm: AES256 51 | Metadata: 52 | cfn_nag: 53 | rules_to_suppress: 54 | - id: W35 55 | reason: S3 Bucket access logging not needed here. 56 | - id: W51 57 | reason: Bucket policy not needed here. 58 | CloudFrontOriginAccessIdentity: 59 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 60 | Properties: 61 | CloudFrontOriginAccessIdentityConfig: 62 | Comment: "S3 CloudFront OAI" 63 | CloudFrontDistribution: 64 | Type: AWS::CloudFront::Distribution 65 | Properties: 66 | DistributionConfig: 67 | DefaultCacheBehavior: 68 | ForwardedValues: 69 | QueryString: false 70 | TargetOriginId: the-s3-bucket 71 | ViewerProtocolPolicy: redirect-to-https 72 | DefaultRootObject: index.html 73 | Enabled: true 74 | Logging: 75 | Bucket: !Sub "${LoggingBucket}.s3.amazonaws.com" 76 | Prefix: "cloudfront-distribution/" 77 | Origins: 78 | - DomainName: !Sub "${StaticWebsiteBucket}.s3.${AWS::Region}.amazonaws.com" 79 | Id: the-s3-bucket 80 | S3OriginConfig: 81 | OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" 82 | Metadata: 83 | cfn_nag: 84 | rules_to_suppress: 85 | - id: W70 86 | reason: Minimum protocol version not supported with distribution that uses the CloudFront domain name. 87 | BucketPolicy: 88 | Type: AWS::S3::BucketPolicy 89 | Properties: 90 | Bucket: !Ref StaticWebsiteBucket 91 | PolicyDocument: 92 | Statement: 93 | - Effect: Allow 94 | Action: 95 | - s3:GetObject 96 | Principal: 97 | CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId 98 | Resource: !Sub "arn:aws:s3:::${StaticWebsiteBucket}/*" 99 | - Effect: Deny 100 | Action: "*" 101 | Principal: "*" 102 | Resource: 103 | - !Sub "arn:aws:s3:::${StaticWebsiteBucket}/*" 104 | - !Sub "arn:aws:s3:::${StaticWebsiteBucket}" 105 | Condition: 106 | Bool: 107 | aws:SecureTransport: false 108 | WebsiteCustomResource: 109 | Type: Custom::WebsiteCustomResource 110 | Properties: 111 | ServiceToken: !GetAtt WebsiteCustomResourceFunction.Arn 112 | InputBucket: !Ref BuildBucket 113 | InputKey: !Ref BuildKey 114 | OutputBucket: !Ref StaticWebsiteBucket 115 | EndpointURL: !Ref EndpointURL 116 | UserPoolId: !Ref UserPoolId 117 | UserPoolWebClientId: !Ref UserPoolWebClientId 118 | AwsRegion: !Ref AWS::Region 119 | WebsiteCustomResourceFunction: 120 | Type: AWS::Lambda::Function 121 | Properties: 122 | Handler: index.handler 123 | Role: !GetAtt WebsiteCustomResourceFunctionRole.Arn 124 | Timeout: 300 125 | Runtime: python3.9 126 | Code: 127 | ZipFile: | 128 | import zipfile 129 | from pathlib import Path 130 | import boto3 131 | import cfnresponse 132 | 133 | MIME_BY_EXTENSION = { 134 | 'ico': 'image/x-icon', 135 | 'html': 'text/html', 136 | 'json': 'application/json', 137 | 'txt': 'text/plain', 138 | 'css': 'text/css', 139 | 'js': 'application/javascript', 140 | 'svg': 'image/svg+xml', 141 | 'png': 'image/png' 142 | } 143 | 144 | def copy_files(input_bucket, input_key, output_bucket, replacements): 145 | s3_client = boto3.client('s3') 146 | filename = input_key.split('/')[-1] 147 | download_path = f'/tmp/{filename}' 148 | print('Downloading client static files') 149 | s3_client.download_file(input_bucket, input_key, download_path) 150 | zip_content_path = '/tmp/zip-content/' 151 | with zipfile.ZipFile(download_path, 'r') as zip_ref: 152 | zip_ref.extractall(zip_content_path) 153 | for local_path in Path(zip_content_path).glob('**/*.*'): 154 | for replacement in replacements: 155 | try: 156 | local_path.write_text(local_path.read_text().replace(replacement[0], replacement[1])) 157 | except UnicodeDecodeError: 158 | pass 159 | local_path_str = str(local_path) 160 | key = local_path_str.replace(zip_content_path, '') 161 | extension = local_path_str.split('.')[-1] 162 | extra_args = {'ContentType': MIME_BY_EXTENSION[extension]} if extension in MIME_BY_EXTENSION else {} 163 | print(f'Copying {local_path_str} to s3://{output_bucket}/{key} ExtraArgs={extra_args}') 164 | s3_client.upload_file(local_path_str, output_bucket, key, ExtraArgs=extra_args) 165 | 166 | def handler(event, context): 167 | request_type = event['RequestType'] 168 | request_properties = event['ResourceProperties'] 169 | input_bucket = request_properties['InputBucket'] 170 | input_key = request_properties['InputKey'] 171 | output_bucket = request_properties['OutputBucket'] 172 | response_data = {} 173 | try: 174 | if request_type == 'Create': 175 | replacements = [ 176 | ('%%ENDPOINT_URL%%', request_properties['EndpointURL']), 177 | ('%%USER_POOL_ID%%', request_properties['UserPoolId']), 178 | ('%%USER_POOL_WEB_CLIENT_ID%%', request_properties['UserPoolWebClientId']), 179 | ('%%AWS_REGION%%', request_properties['AwsRegion']) 180 | ] 181 | copy_files(input_bucket, input_key, output_bucket, replacements) 182 | elif request_type == 'Update': 183 | pass 184 | elif request_type == 'Delete': 185 | s3_resource = boto3.resource('s3') 186 | s3_resource.Bucket(output_bucket).objects.all().delete() 187 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data) 188 | except Exception as e: 189 | response_data['Data'] = str(e) 190 | cfnresponse.send(event, context, cfnresponse.FAILED, response_data) 191 | Metadata: 192 | cfn_nag: 193 | rules_to_suppress: 194 | - id: W89 195 | reason: "This function does not need to access any resource provisioned within a VPC." 196 | - id: W92 197 | reason: "This function does not need performance optimization, so the default limit suffice." 198 | WebsiteCustomResourceFunctionRole: 199 | Type: AWS::IAM::Role 200 | Properties: 201 | AssumeRolePolicyDocument: 202 | Statement: 203 | - Action: 204 | - sts:AssumeRole 205 | Effect: Allow 206 | Principal: 207 | Service: 208 | - lambda.amazonaws.com 209 | Version: '2012-10-17' 210 | Path: '/' 211 | Policies: 212 | - PolicyDocument: 213 | Statement: 214 | - Action: 215 | - logs:CreateLogGroup 216 | - logs:CreateLogStream 217 | - logs:PutLogEvents 218 | Effect: Allow 219 | Resource: arn:aws:logs:*:*:* 220 | Version: '2012-10-17' 221 | PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-WebsiteCustomResource-CW 222 | - PolicyDocument: 223 | Statement: 224 | - Action: 225 | - s3:GetObject 226 | - s3:ListBucket 227 | - s3:GetBucketLocation 228 | - s3:GetObjectVersion 229 | - s3:GetLifecycleConfiguration 230 | Effect: Allow 231 | Resource: 232 | - !Sub arn:aws:s3:::${BuildBucket}/* 233 | - !Sub arn:aws:s3:::${BuildBucket} 234 | Version: '2012-10-17' 235 | PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-WebsiteCustomResource-S3Read 236 | - PolicyDocument: 237 | Statement: 238 | - Action: 239 | - s3:GetObject 240 | - s3:ListBucket 241 | - s3:GetBucketLocation 242 | - s3:GetObjectVersion 243 | - s3:PutObject 244 | - s3:PutObjectAcl 245 | - s3:GetLifecycleConfiguration 246 | - s3:PutLifecycleConfiguration 247 | - s3:DeleteObject 248 | Effect: Allow 249 | Resource: 250 | - !Sub arn:aws:s3:::${StaticWebsiteBucket}/* 251 | - !Sub arn:aws:s3:::${StaticWebsiteBucket} 252 | Version: '2012-10-17' 253 | PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-WebsiteCustomResource-S3Crud 254 | Outputs: 255 | WebsiteURL: 256 | Value: !Sub "https://${CloudFrontDistribution.DomainName}/" 257 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /source/backend/resources.yaml: -------------------------------------------------------------------------------- 1 | Parameters: 2 | SendAnonymousData: 3 | Type: String 4 | Default: '' 5 | SolutionIdentifier: 6 | Type: String 7 | Default: '' 8 | LambdaCodeUriBucket: 9 | Type: String 10 | Default: '' 11 | LambdaCodeUriKey: 12 | Type: String 13 | Default: '' 14 | CognitoUserPoolArn: 15 | Type: String 16 | Resources: 17 | ChallengeBucket: 18 | Type: AWS::S3::Bucket 19 | DeletionPolicy: Retain 20 | Properties: 21 | VersioningConfiguration: 22 | Status: Enabled 23 | BucketEncryption: 24 | ServerSideEncryptionConfiguration: 25 | - ServerSideEncryptionByDefault: 26 | SSEAlgorithm: AES256 27 | PublicAccessBlockConfiguration: 28 | BlockPublicAcls : true 29 | BlockPublicPolicy : true 30 | IgnorePublicAcls : true 31 | RestrictPublicBuckets : true 32 | LoggingConfiguration: 33 | DestinationBucketName: !Ref LoggingBucket 34 | LogFilePrefix: "challenges-bucket/" 35 | ChallengeBucketPolicy: 36 | Type: AWS::S3::BucketPolicy 37 | Properties: 38 | Bucket: !Ref ChallengeBucket 39 | PolicyDocument: 40 | Statement: 41 | - Effect: Deny 42 | Principal: "*" 43 | Action: "*" 44 | Resource: 45 | - !Sub "arn:aws:s3:::${ChallengeBucket}/*" 46 | - !Sub "arn:aws:s3:::${ChallengeBucket}" 47 | Condition: 48 | Bool: 49 | aws:SecureTransport: false 50 | LoggingBucket: 51 | Type: AWS::S3::Bucket 52 | DeletionPolicy: Retain 53 | Properties: 54 | VersioningConfiguration: 55 | Status: Enabled 56 | PublicAccessBlockConfiguration: 57 | BlockPublicAcls : true 58 | BlockPublicPolicy : true 59 | IgnorePublicAcls : true 60 | RestrictPublicBuckets : true 61 | AccessControl: LogDeliveryWrite 62 | BucketEncryption: 63 | ServerSideEncryptionConfiguration: 64 | - ServerSideEncryptionByDefault: 65 | SSEAlgorithm: AES256 66 | Metadata: 67 | cfn_nag: 68 | rules_to_suppress: 69 | - id: W35 70 | reason: S3 Bucket access logging not needed here. 71 | LoggingBucketPolicy: 72 | Type: AWS::S3::BucketPolicy 73 | Properties: 74 | Bucket: !Ref LoggingBucket 75 | PolicyDocument: 76 | Statement: 77 | - Effect: Deny 78 | Principal: "*" 79 | Action: "*" 80 | Resource: 81 | - !Sub "arn:aws:s3:::${LoggingBucket}/*" 82 | - !Sub "arn:aws:s3:::${LoggingBucket}" 83 | Condition: 84 | Bool: 85 | aws:SecureTransport: false 86 | TrailBucket: 87 | Type: AWS::S3::Bucket 88 | DeletionPolicy: Retain 89 | Properties: 90 | VersioningConfiguration: 91 | Status: Enabled 92 | PublicAccessBlockConfiguration: 93 | BlockPublicAcls : true 94 | BlockPublicPolicy : true 95 | IgnorePublicAcls : true 96 | RestrictPublicBuckets : true 97 | BucketEncryption: 98 | ServerSideEncryptionConfiguration: 99 | - ServerSideEncryptionByDefault: 100 | SSEAlgorithm: AES256 101 | Metadata: 102 | cfn_nag: 103 | rules_to_suppress: 104 | - id: W35 105 | reason: S3 Bucket access logging not needed here. 106 | TrailBucketPolicy: 107 | Type: AWS::S3::BucketPolicy 108 | Properties: 109 | Bucket: !Ref TrailBucket 110 | PolicyDocument: 111 | Statement: 112 | - Effect: Allow 113 | Principal: 114 | Service: cloudtrail.amazonaws.com 115 | Action: s3:GetBucketAcl 116 | Resource: !Sub 'arn:aws:s3:::${TrailBucket}' 117 | - Effect: Allow 118 | Principal: 119 | Service: cloudtrail.amazonaws.com 120 | Action: s3:PutObject 121 | Resource: !Sub 'arn:aws:s3:::${TrailBucket}/AWSLogs/${AWS::AccountId}/*' 122 | Condition: 123 | StringEquals: 124 | s3:x-amz-acl: bucket-owner-full-control 125 | - Effect: Deny 126 | Principal: "*" 127 | Action: "*" 128 | Resource: 129 | - !Sub "arn:aws:s3:::${TrailBucket}/*" 130 | - !Sub "arn:aws:s3:::${TrailBucket}" 131 | Condition: 132 | Bool: 133 | aws:SecureTransport: false 134 | Trail: 135 | Type: AWS::CloudTrail::Trail 136 | DependsOn: TrailBucketPolicy 137 | Properties: 138 | TrailName: !Ref AWS::StackName 139 | S3BucketName: !Ref TrailBucket 140 | IsLogging: true 141 | ChallengeTable: 142 | Type: AWS::DynamoDB::Table 143 | DeletionPolicy: Retain 144 | Properties: 145 | AttributeDefinitions: 146 | - AttributeName: id 147 | AttributeType: S 148 | KeySchema: 149 | - AttributeName: id 150 | KeyType: HASH 151 | PointInTimeRecoverySpecification: 152 | PointInTimeRecoveryEnabled: true 153 | BillingMode: PAY_PER_REQUEST 154 | Metadata: 155 | cfn_nag: 156 | rules_to_suppress: 157 | - id: W74 158 | reason: Server-side encryption is done using an AWS owned key 159 | TokenSecret: 160 | Type: AWS::SecretsManager::Secret 161 | Properties: 162 | GenerateSecretString: {} 163 | Metadata: 164 | cfn_nag: 165 | rules_to_suppress: 166 | - id: W77 167 | reason: "Neither key control nor cross-account sharing are required" 168 | APIGatewayAccount: 169 | Type: AWS::ApiGateway::Account 170 | Properties: 171 | CloudWatchRoleArn: !GetAtt APIGatewayLogRole.Arn 172 | APIGatewayLogRole: 173 | Type: AWS::IAM::Role 174 | Properties: 175 | Policies: 176 | - PolicyName: AmazonAPIGatewayPushToCloudWatchLogs 177 | PolicyDocument: 178 | Statement: 179 | - Action: 180 | - logs:CreateLogGroup 181 | - logs:CreateLogStream 182 | - logs:DescribeLogGroups 183 | - logs:DescribeLogStreams 184 | - logs:PutLogEvents 185 | - logs:GetLogEvents 186 | - logs:FilterLogEvents 187 | Effect: Allow 188 | Resource: arn:*:logs:*:*:* 189 | AssumeRolePolicyDocument: 190 | Version: 2012-10-17 191 | Statement: 192 | - Effect: Allow 193 | Principal: 194 | Service: 195 | - apigateway.amazonaws.com 196 | Action: sts:AssumeRole 197 | Path: / 198 | RestAPILogGroup: 199 | Type: AWS::Logs::LogGroup 200 | Properties: 201 | RetentionInDays: 30 202 | Metadata: 203 | cfn_nag: 204 | rules_to_suppress: 205 | - id: W84 206 | reason: "Not using AWS KMS customer managed key." 207 | RestAPI: 208 | Type: AWS::Serverless::Api 209 | Properties: 210 | MethodSettings: 211 | - ResourcePath: '/*' 212 | HttpMethod: '*' 213 | LoggingLevel: INFO 214 | MetricsEnabled: true 215 | DataTraceEnabled: false 216 | AccessLogSetting: 217 | DestinationArn: !GetAtt RestAPILogGroup.Arn 218 | Format: '$context.identity.sourceIp $context.identity.caller $context.identity.user [$context.requestTime] "$context.httpMethod $context.resourcePath $context.protocol" $context.status $context.responseLength $context.requestId' 219 | DefinitionBody: 220 | swagger: 2.0 221 | definitions: 222 | CreateChallenge: 223 | type: object 224 | required: 225 | - imageWidth 226 | - imageHeight 227 | properties: 228 | imageWidth: 229 | type: integer 230 | imageHeight: 231 | type: integer 232 | additionalProperties: 233 | type: string 234 | PutChallengeFrame: 235 | type: object 236 | required: 237 | - token 238 | - timestamp 239 | - frameBase64 240 | properties: 241 | token: 242 | type: string 243 | timestamp: 244 | type: integer 245 | frameBase64: 246 | type: string 247 | additionalProperties: false 248 | VerifyChallengeResponse: 249 | type: object 250 | required: 251 | - token 252 | properties: 253 | token: 254 | type: string 255 | additionalProperties: false 256 | x-amazon-apigateway-request-validators: 257 | all: 258 | validateRequestBody: true 259 | validateRequestParameters: true 260 | x-amazon-apigateway-request-validator: all 261 | paths: 262 | /challenge: 263 | post: 264 | x-amazon-apigateway-request-validator: all 265 | parameters: 266 | - required: true 267 | in: body 268 | name: CreateChallenge 269 | schema: 270 | $ref: '#/definitions/CreateChallenge' 271 | /challenge/{challenge_id}/frame: 272 | put: 273 | x-amazon-apigateway-request-validator: all 274 | parameters: 275 | - in: path 276 | name: challenge_id 277 | required: true 278 | type: string 279 | - required: true 280 | in: body 281 | name: PutChallengeFrame 282 | schema: 283 | $ref: '#/definitions/PutChallengeFrame' 284 | /challenge/{challenge_id}/verify: 285 | post: 286 | x-amazon-apigateway-request-validator: all 287 | parameters: 288 | - in: path 289 | name: challenge_id 290 | required: true 291 | type: string 292 | - required: true 293 | in: body 294 | name: VerifyChallengeResponse 295 | schema: 296 | $ref: '#/definitions/VerifyChallengeResponse' 297 | APIHandler: 298 | Properties: 299 | Environment: 300 | Variables: 301 | CLIENT_CHALLENGE_SELECTION: True 302 | ACCOUNT_ID: 303 | Ref: AWS::AccountId 304 | REGION_NAME: 305 | Ref: AWS::Region 306 | BUCKET_NAME: 307 | Ref: ChallengeBucket 308 | TABLE_NAME: 309 | Ref: ChallengeTable 310 | TOKEN_SECRET: 311 | Ref: TokenSecret 312 | SOLUTION_IDENTIFIER: 313 | Ref: SolutionIdentifier 314 | SEND_ANONYMOUS_USAGE_DATA: 315 | Ref: SendAnonymousData 316 | COGNITO_USER_POOL_ARN: 317 | Ref: CognitoUserPoolArn 318 | Metadata: 319 | cfn_nag: 320 | rules_to_suppress: 321 | - id: W89 322 | reason: "This function does not need to access any resource provisioned within a VPC." 323 | - id: W92 324 | reason: "This function does not need performance optimization, so the default limit suffice." 325 | DefaultRole: 326 | Properties: 327 | Policies: 328 | - PolicyDocument: 329 | Statement: 330 | - Action: 331 | - s3:PutObject 332 | - s3:GetObject 333 | Effect: Allow 334 | Resource: 335 | - !Sub "arn:aws:s3:::${ChallengeBucket}/*" 336 | - Action: 337 | - dynamodb:GetItem 338 | - dynamodb:PutItem 339 | - dynamodb:UpdateItem 340 | Effect: Allow 341 | Resource: 342 | - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ChallengeTable}" 343 | - Action: 344 | - rekognition:DetectFaces 345 | Effect: Allow 346 | Resource: 347 | - "*" 348 | - Action: 349 | - logs:CreateLogGroup 350 | - logs:CreateLogStream 351 | - logs:PutLogEvents 352 | Effect: Allow 353 | Resource: arn:*:logs:*:*:* 354 | - Action: 355 | - secretsmanager:GetResourcePolicy 356 | - secretsmanager:GetSecretValue 357 | - secretsmanager:DescribeSecret 358 | - secretsmanager:ListSecretVersionIds 359 | - secretsmanager:ListSecrets 360 | Effect: Allow 361 | Resource: 362 | Ref: TokenSecret 363 | Version: "2012-10-17" 364 | PolicyName: DefaultRolePolicy 365 | Metadata: 366 | cfn_nag: 367 | rules_to_suppress: 368 | - id: W11 369 | reason: "Rekognition action must be applied to all resources" 370 | Outputs: 371 | TableName: 372 | Value: !Ref ChallengeTable 373 | TokenSecretArn: 374 | Value: !Ref TokenSecret 375 | -------------------------------------------------------------------------------- /source/backend/chalicelib/framework.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import base64 5 | import binascii 6 | import imghdr 7 | import decimal 8 | import functools 9 | import json 10 | import os 11 | import secrets 12 | import uuid 13 | from concurrent.futures import ThreadPoolExecutor, as_completed 14 | 15 | import boto3 16 | from botocore import config 17 | from botocore.exceptions import ClientError 18 | from chalice import Blueprint, CognitoUserPoolAuthorizer, BadRequestError, NotFoundError, UnauthorizedError 19 | 20 | from .jwt_manager import JwtManager 21 | 22 | blueprint = Blueprint(__name__) 23 | 24 | STATE_NEXT = 1 25 | STATE_CONTINUE = 0 26 | CHALLENGE_FAIL = -1 27 | CHALLENGE_SUCCESS = 2 28 | 29 | _FAIL_STATE = '_FAIL_STATE' 30 | _FIRST_STATE = '_FIRST_STATE' 31 | 32 | _REGION_NAME = os.getenv('REGION_NAME') 33 | _BUCKET_NAME = os.getenv('BUCKET_NAME') 34 | _TABLE_NAME = os.getenv('TABLE_NAME') 35 | _THREAD_POOL_SIZE = int(os.getenv('THREAD_POOL_SIZE', 10)) 36 | _SEND_ANONYMOUS_USAGE_DATA = os.getenv('SEND_ANONYMOUS_USAGE_DATA', 'False').upper() == 'TRUE' 37 | 38 | _MAX_IMAGE_SIZE = 15728640 39 | 40 | _extra_params = {} 41 | if _SEND_ANONYMOUS_USAGE_DATA and 'SOLUTION_IDENTIFIER' in os.environ: 42 | _extra_params['user_agent_extra'] = os.environ['SOLUTION_IDENTIFIER'] 43 | config = config.Config(**_extra_params) 44 | 45 | _s3 = boto3.client('s3', region_name=_REGION_NAME, config=config) 46 | _rek = boto3.client('rekognition', region_name=_REGION_NAME, config=config) 47 | _table = boto3.resource('dynamodb', region_name=_REGION_NAME, config=config).Table(_TABLE_NAME) if _TABLE_NAME else None 48 | 49 | _challenge_types = [] 50 | _challenge_params_funcs = dict() 51 | _challenge_state_funcs = dict() 52 | 53 | _challenge_type_selector_func = [lambda client_metadata: secrets.choice(_challenge_types)] 54 | 55 | _jwt_manager = JwtManager(os.getenv('TOKEN_SECRET')) 56 | 57 | 58 | authorizer = CognitoUserPoolAuthorizer('LivenessUserPool', provider_arns=[os.getenv('COGNITO_USER_POOL_ARN', 59 | '%%REF_COGNITO_USER_POOL_ARN%%')]) 60 | 61 | 62 | def challenge_type_selector(func): 63 | blueprint.log.debug('registering challenge_type_selector: %s', func.__name__) 64 | _challenge_type_selector_func[0] = func 65 | return func 66 | 67 | 68 | def challenge_params(challenge_type): 69 | def decorator(func): 70 | if challenge_type not in _challenge_types: 71 | _challenge_types.append(challenge_type) 72 | _challenge_params_funcs[challenge_type] = func 73 | return func 74 | 75 | return decorator 76 | 77 | 78 | def check_state_timeout(func, end_times, frame, timeout): 79 | frame_timestamp = frame['timestamp'] 80 | if func.__name__ not in end_times: 81 | end_times[func.__name__] = frame_timestamp + timeout * 1000 82 | elif frame_timestamp > end_times[func.__name__]: 83 | blueprint.log.debug('State timed out: %s', frame_timestamp) 84 | raise _Fail 85 | 86 | 87 | def run_state_processing_function(func, challenge, context, frame): 88 | try: 89 | res = func(challenge, frame, context) 90 | except Exception as e: 91 | blueprint.log.error('Exception: %s', e) 92 | raise e 93 | return res 94 | 95 | 96 | def challenge_state(challenge_type, first=False, next_state=_FAIL_STATE, timeout=10): 97 | def decorator(func): 98 | @functools.wraps(func) 99 | def wrapper(challenge, frame, context, end_times): 100 | check_state_timeout(func, end_times, frame, timeout) 101 | res = run_state_processing_function(func, challenge, context, frame) 102 | blueprint.log.debug('res: %s', res) 103 | # Check result 104 | if res == STATE_CONTINUE: 105 | return wrapper 106 | if res == STATE_NEXT: 107 | return _challenge_state_funcs[challenge_type][next_state] 108 | if res == CHALLENGE_SUCCESS: 109 | raise _Success 110 | if res == CHALLENGE_FAIL: 111 | raise _Fail 112 | 113 | # Register challenge type (if not yet) 114 | if challenge_type not in _challenge_types: 115 | _challenge_types.append(challenge_type) 116 | # Create challenge type's state list with default fail state (if not yet) 117 | if challenge_type not in _challenge_state_funcs: 118 | _challenge_state_funcs[challenge_type] = dict() 119 | _challenge_state_funcs[challenge_type][_FAIL_STATE] = lambda: CHALLENGE_FAIL 120 | # Register state for challenge type 121 | _challenge_state_funcs[challenge_type][func.__name__] = wrapper 122 | # Register as fist state (if first is true) 123 | if first: 124 | _challenge_state_funcs[challenge_type][_FIRST_STATE] = wrapper 125 | return wrapper 126 | 127 | return decorator 128 | 129 | 130 | class _Success(Exception): 131 | pass 132 | 133 | 134 | class _Fail(Exception): 135 | pass 136 | 137 | 138 | def jwt_token_auth(func): 139 | def inner(challenge_id): 140 | blueprint.log.debug('Starting jwt_token_auth decorator') 141 | try: 142 | request = blueprint.current_request.json_body 143 | token = request['token'] 144 | blueprint.log.debug(f'Authorization header (JWT): {token}') 145 | jwt_challenge_id = _jwt_manager.get_challenge_id(token) 146 | blueprint.log.debug(f'Authorization header challenge id: {jwt_challenge_id}') 147 | blueprint.log.debug(f'Request challenge id: {challenge_id}') 148 | if challenge_id != jwt_challenge_id: 149 | raise AssertionError() 150 | except Exception: 151 | blueprint.log.debug('Could not verify challenge id') 152 | raise UnauthorizedError() 153 | blueprint.log.debug('Challenge id successfully verified') 154 | return func(challenge_id) 155 | 156 | return inner 157 | 158 | 159 | @blueprint.route('/challenge', methods=['POST'], cors=True, authorizer=authorizer) 160 | def create_challenge(): 161 | blueprint.log.debug('create_challenge') 162 | client_metadata = blueprint.current_request.json_body 163 | # Validating client metadata input 164 | if 'imageWidth' not in client_metadata or 'imageHeight' not in client_metadata: 165 | raise BadRequestError('Missing imageWidth and imageHeight') 166 | try: 167 | int(client_metadata['imageWidth']) 168 | except ValueError: 169 | raise BadRequestError('Invalid imageWidth') 170 | try: 171 | int(client_metadata['imageHeight']) 172 | except ValueError: 173 | raise BadRequestError('Invalid imageHeight') 174 | blueprint.log.debug('client_metadata: %s', client_metadata) 175 | # Saving challenge on DynamoDB table 176 | challenge = dict() 177 | challenge_id = str(uuid.uuid1()) 178 | challenge['id'] = challenge_id 179 | challenge['token'] = _jwt_manager.get_jwt_token(challenge_id) 180 | challenge['type'] = _challenge_type_selector_func[0](client_metadata) 181 | challenge['params'] = _challenge_params_funcs[challenge['type']](client_metadata) 182 | blueprint.log.debug('challenge: %s', challenge) 183 | _table.put_item(Item=challenge) 184 | return challenge 185 | 186 | 187 | @blueprint.route('/challenge/{challenge_id}/frame', methods=['PUT'], cors=True, authorizer=authorizer) 188 | @jwt_token_auth 189 | def put_challenge_frame(challenge_id): 190 | blueprint.log.debug('put_challenge_frame: %s', challenge_id) 191 | request = blueprint.current_request.json_body 192 | # Validating timestamp input 193 | try: 194 | timestamp = int(request['timestamp']) 195 | except ValueError: 196 | raise BadRequestError('Invalid timestamp') 197 | blueprint.log.debug('timestamp: %s', timestamp) 198 | # Validating frame input 199 | try: 200 | frame = base64.b64decode(request['frameBase64'], validate=True) 201 | except binascii.Error: 202 | raise BadRequestError('Invalid Image') 203 | if len(frame) > _MAX_IMAGE_SIZE: 204 | raise BadRequestError('Image size too large') 205 | if imghdr.what(None, h=frame) != 'jpeg': 206 | raise BadRequestError('Image must be JPEG') 207 | frame_key = '{}/{}.jpg'.format(challenge_id, timestamp) 208 | blueprint.log.debug('frame_key: %s', frame_key) 209 | # Updating challenge on DynamoDB table 210 | try: 211 | _table.update_item( 212 | Key={'id': challenge_id}, 213 | UpdateExpression='set #frames = list_append(if_not_exists(#frames, :empty_list), :frame)', 214 | ExpressionAttributeNames={'#frames': 'frames'}, 215 | ExpressionAttributeValues={ 216 | ':empty_list': [], 217 | ':frame': [{ 218 | 'timestamp': timestamp, 219 | 'key': frame_key 220 | }] 221 | }, 222 | ReturnValues='NONE' 223 | ) 224 | except ClientError as error: 225 | if error.response['Error']['Code'] == 'ConditionalCheckFailedException': 226 | blueprint.log.info('Challenge not found: %s', challenge_id) 227 | raise NotFoundError('Challenge not found') 228 | # Uploading frame to S3 bucket 229 | _s3.put_object( 230 | Body=frame, 231 | Bucket=_BUCKET_NAME, 232 | Key=frame_key, 233 | ExpectedBucketOwner=os.getenv('ACCOUNT_ID') # Bucket Sniping prevention 234 | ) 235 | return {'message': 'Frame saved successfully'} 236 | 237 | 238 | @blueprint.route('/challenge/{challenge_id}/verify', methods=['POST'], cors=True, authorizer=authorizer) 239 | @jwt_token_auth 240 | def verify_challenge_response(challenge_id): 241 | blueprint.log.debug('verify_challenge_response: %s', challenge_id) 242 | # Looking up challenge on DynamoDB table 243 | item = _table.get_item(Key={'id': challenge_id}) 244 | if 'Item' not in item: 245 | blueprint.log.info('Challenge not found: %s', challenge_id) 246 | raise NotFoundError('Challenge not found') 247 | challenge = _read_item(item['Item']) 248 | blueprint.log.debug('challenge: %s', challenge) 249 | # Getting challenge type, params and frames 250 | challenge_type = challenge['type'] 251 | params = challenge['params'] 252 | frames = challenge['frames'] 253 | # Invoking Rekognition with parallel threads 254 | with ThreadPoolExecutor(max_workers=_THREAD_POOL_SIZE) as pool: 255 | futures = [ 256 | pool.submit( 257 | _detect_faces, frame 258 | ) for frame in frames 259 | ] 260 | frames = [r.result() for r in as_completed(futures)] 261 | frames.sort(key=lambda frame: frame['key']) 262 | current_state = _challenge_state_funcs[challenge_type][_FIRST_STATE] 263 | context = dict() 264 | end_times = dict() 265 | success = False 266 | for frame in frames: 267 | try: 268 | while True: 269 | blueprint.log.debug('----------------') 270 | blueprint.log.debug('current_state: %s', current_state.__name__) 271 | blueprint.log.debug('frame[timestamp]: %s', frame['timestamp']) 272 | blueprint.log.debug('context.keys: %s', context.keys()) 273 | blueprint.log.debug('end_times: %s', end_times) 274 | next_state = current_state(params, frame, context, end_times) 275 | if next_state.__name__ != current_state.__name__: 276 | current_state = next_state 277 | blueprint.log.debug('NEXT') 278 | else: 279 | blueprint.log.debug('CONTINUE') 280 | break 281 | except _Success: 282 | success = True 283 | break 284 | except _Fail: 285 | break 286 | # Returning result based on final state 287 | blueprint.log.debug('success: %s', success) 288 | response = {'success': success} 289 | blueprint.log.debug('response: %s', response) 290 | # Updating challenge on DynamoDB table 291 | _table.update_item( 292 | Key={'id': challenge_id}, 293 | UpdateExpression='set #frames = :frames, #success = :success', 294 | ExpressionAttributeNames={ 295 | '#frames': 'frames', 296 | '#success': 'success' 297 | }, 298 | ExpressionAttributeValues={ 299 | ':frames': _write_item(frames), 300 | ':success': response['success'] 301 | }, 302 | ReturnValues='NONE' 303 | ) 304 | return response 305 | 306 | 307 | def _detect_faces(frame): 308 | frame['rekMetadata'] = _rek.detect_faces( 309 | Attributes=['ALL'], 310 | Image={ 311 | 'S3Object': { 312 | 'Bucket': _BUCKET_NAME, 313 | 'Name': frame['key'] 314 | } 315 | } 316 | )['FaceDetails'] 317 | return frame 318 | 319 | 320 | def _read_item(item): 321 | return json.loads(json.dumps(item, cls=_DecimalEncoder)) 322 | 323 | 324 | def _write_item(item): 325 | return json.loads(json.dumps(item), parse_float=decimal.Decimal) 326 | 327 | 328 | # Helper class to convert a DynamoDB item to JSON. 329 | class _DecimalEncoder(json.JSONEncoder): 330 | def default(self, o): 331 | if isinstance(o, decimal.Decimal): 332 | if o % 1 > 0: 333 | return float(o) 334 | return int(o) 335 | return super(_DecimalEncoder, self).default(o) 336 | --------------------------------------------------------------------------------