Question: Verifying user is authenticated using AWS IOS SDK

Question

Verifying user is authenticated using AWS IOS SDK

Answers 2
Added at 2016-12-30 09:12
Tags
Question

I created a lamdba function which does the following:

var param =
{
    IdentityPoolId: "us-east-1:the-full-identity-id",
    Logins: {} // To have provider name in a variable
};
param.Logins["com.test.website.login"] = userIdICreatedAndStoredInDynamoDB;

cognitoidentity.getOpenIdTokenForDeveloperIdentity(param,
function(err, data)
{
    if (err) return fn(err); // an error occurred
    else fn(null, data.IdentityId, data.Token); // successful response
});

It returns the identityId and token for that user. Everything is setup with IAM roles and AWS Cognito Identity and appears to be authenticating in the console.

I have two questions:

  1. How do I test in the app that the user is authenticated? I save the identityId and token in the app device.
  2. How long does the authentication last? I want the user to remain logged in. This is how most apps I use work and stays logged in until they hit logout.

Thanks.

Answers to

Verifying user is authenticated using AWS IOS SDK

nr: #1 dodano: 2016-12-30 15:12

To test if they're logged in you need to set up a service that'll check the token against Cognito. Quick and dirty way is to set up a basic lambda, expose it through API Gateway with an authorizer pointed at your User Identity Pool. All the lambda needs to do is return HTTP 200, since what you're really checking is the authorizer. Then have your app get/post/etc to that API URL w/ a header of "Authorization":$ACCESS_TOKEN. either it'll kick back a 200 on success or it'll return an Unauthorized message.

Your Cognito token is only good for an hour, but you can refresh the token to keep a person logged in. When your user authenticated they got three tokens: ID, Access, and Refresh token. You can use the latter to request a new access token.

It is documented at : http://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html

nr: #2 dodano: 2017-01-02 14:01

To answer the first question:

How do I test in the app that the user is authenticated? I save the identityId and token in the app device.

You test the authentication by making a "Custom Authorizer"

The AWS example function you can find in the Lambda Example Functions when you go to make a new function (if you filter to NodeJS 4.3 functions, it's towards the back)

Or you can take a look at THIS which is the same thing, just on GitHub instead.

I made a sorta modified version here:

"use strict";

const
    codes  = {
        100: "Continue", 101: "Switching Protocols", 102: "Processing",
        200: "OK", 201: "Created", 202: "Accepted", 203: "Non-Authoritative Information", 204: "No Content", 205: "Reset Content", 206: "Partial Content", 207: "Multi-Status", 208: "Already Reported", 226: "IM Used",
        300: "Multiple Choices", 301: "Moved Permanently", 302: "Found", 303: "See Other", 304: "Not Modified", 305: "Use Proxy", 307: "Temporary Redirect", 308: "Permanent Redirect",
        400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable", 407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict", 410: "Gone", 411: "Length Required", 412: "Precondition Failed", 413: "Payload Too Large", 414: "URI Too Long",
        415: "Unsupported Media Type", 416: "Range Not Satisfiable", 417: "Expectation Failed", 418: "I'm a teapot", 421: "Misdirected Request", 422: "Unprocessable Entity", 423: "Locked", 424: "Failed Dependency", 425: "Unordered Collection", 426: "Upgrade Required", 428: "Precondition Required", 429: "Too Many Requests", 431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons",
        500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout", 505: "HTTP Version Not Supported", 506: "Variant Also Negotiates", 507: "Insufficient Storage", 508: "Loop Detected", 509: "Bandwidth Limit Exceeded", 510: "Not Extended", 511: "Network Authentication Required"
    },
    resp   = ( statusCode, data ) => ( { statusCode, message: codes[ statusCode ], data } ),
    AWS    = require( "aws-sdk" ),
    crypto = require( "crypto" ),
    COG    = new AWS.CognitoIdentity(),
    token  = {
        algorithm: "aes-256-ctr",
        encrypt: item => {
            item = JSON.stringify( item );
            let cipher = crypto.createCipher( token.algorithm, process.env.PoolId ),
                crypted = cipher.update( item, 'utf8', 'base64' );
            crypted += cipher.final( 'base64' );
            return crypted;
        },
        decrypt: item => {
            let decipher = crypto.createDecipher( token.algorithm, process.env.PoolId ),
                dec = decipher.update( item, 'base64', 'utf8' );
            dec += decipher.final( 'utf8' );
            return dec;
        }
    };

function AuthPolicy( principal, awsAccountId, apiOptions ) {
    this.awsAccountId = awsAccountId;
    this.principalId = principal;
    this.version = '2012-10-17';
    this.pathRegex = new RegExp( '^[/.a-zA-Z0-9-\*]+$' );
    this.allowMethods = [];
    this.denyMethods = [];

    if( !apiOptions || !apiOptions.restApiId ) this.restApiId = '*';
    else this.restApiId = apiOptions.restApiId;

    if( !apiOptions || !apiOptions.region ) this.region = '*';
    else this.region = apiOptions.region;

    if( !apiOptions || !apiOptions.stage ) this.stage = '*';
    else this.stage = apiOptions.stage;
}

AuthPolicy.HttpVerb = {
    GET: 'GET',
    POST: 'POST',
    PUT: 'PUT',
    PATCH: 'PATCH',
    HEAD: 'HEAD',
    DELETE: 'DELETE',
    OPTIONS: 'OPTIONS',
    ALL: '*',
};

AuthPolicy.prototype = ( function AuthPolicyClass() {

    function addMethod( effect, verb, resource, conditions ) {
        if( verb !== '*' && !Object.prototype.hasOwnProperty.call( AuthPolicy.HttpVerb, verb ) ) {
            throw new Error( `Invalid HTTP verb ${verb}. Allowed verbs in AuthPolicy.HttpVerb` );
        }

        if( !this.pathRegex.test( resource ) )
            throw new Error( `Invalid resource path: ${resource}. Path should match ${this.pathRegex}` );

        let cleanedResource = resource;

        if( resource.substring( 0, 1 ) === '/' )
            cleanedResource = resource.substring( 1, resource.length );

        const resourceArn = `arn:aws:execute-api:${this.region}:${this.awsAccountId}:${this.restApiId}/${this.stage}/${verb}/${cleanedResource}`;

        if( effect.toLowerCase() === 'allow' )
            this.allowMethods.push( {
                resourceArn,
                conditions,
            } );
        else if( effect.toLowerCase() === 'deny' )
            this.denyMethods.push( {
                resourceArn,
                conditions,
            } );
    }

    function getEmptyStatement( effect ) {
        const statement = {};
        statement.Action = 'execute-api:Invoke';
        statement.Effect = effect.substring( 0, 1 ).toUpperCase() + effect.substring( 1, effect.length ).toLowerCase();
        statement.Resource = [];

        return statement;
    }

    function getStatementsForEffect( effect, methods ) {
        const statements = [];

        if( methods.length > 0 ) {
            const statement = getEmptyStatement( effect );

            for( let i = 0; i < methods.length; i++ ) {
                const curMethod = methods[ i ];
                if( curMethod.conditions === null || curMethod.conditions.length === 0 )
                    statement.Resource.push( curMethod.resourceArn );
                else {
                    const conditionalStatement = getEmptyStatement( effect );
                    conditionalStatement.Resource.push( curMethod.resourceArn );
                    conditionalStatement.Condition = curMethod.conditions;
                    statements.push( conditionalStatement );
                }
            }

            if( statement.Resource !== null && statement.Resource.length > 0 )
                statements.push( statement );
        }
        return statements;
    }

    return {
        constructor: AuthPolicy,
        allowAllMethods() {
            addMethod.call( this, 'allow', '*', '*', null );
        },
        denyAllMethods() {
            addMethod.call( this, 'deny', '*', '*', null );
        },
        allowMethod( verb, resource ) {
            addMethod.call( this, 'allow', verb, resource, null );
        },
        denyMethod( verb, resource ) {
            addMethod.call( this, 'deny', verb, resource, null );
        },
        allowMethodWithConditions( verb, resource, conditions ) {
            addMethod.call( this, 'allow', verb, resource, conditions );
        },
        denyMethodWithConditions( verb, resource, conditions ) {
            addMethod.call( this, 'deny', verb, resource, conditions );
        },
        build() {
            if( ( !this.allowMethods || this.allowMethods.length === 0 ) &&
                ( !this.denyMethods || this.denyMethods.length === 0 ) )
                throw new Error( 'No statements defined for the policy' );

            const policy = {}, doc = {};
            policy.principalId = this.principalId;

            doc.Version = this.version;
            doc.Statement = [];
            doc.Statement = doc.Statement.concat( getStatementsForEffect.call( this, 'Allow', this.allowMethods ) );
            doc.Statement = doc.Statement.concat( getStatementsForEffect.call( this, 'Deny', this.denyMethods ) );

            policy.policyDocument = doc;

            return policy;
        },
    };
} () );


exports.handler = ( event, context, cb ) => {
    const
        principalId      = process.env.principalId,
        tmp              = event.methodArn.split( ':' ),
        apiGatewayArnTmp = tmp[ 5 ].split( '/' ),
        awsAccountId     = tmp[ 4 ],
        apiOptions       = {
            region: tmp[ 3 ],
            restApiId: apiGatewayArnTmp[ 0 ],
            stage: apiGatewayArnTmp[ 1 ]
        },
        policy = new AuthPolicy( principalId, awsAccountId, apiOptions );

    let response;

    if( !event.authorizationToken || typeof event.authorizationToken !== "string" )
        response = resp( 401 );

    let item = token.decrypt( event.authorizationToken );

    try { item = resp( 100, JSON.parse( item ) ); }
    catch( e ) { item = resp( 401 ); }

    if( item.statusCode !== 100 )
        response = resp( 401 );
    else if( item.data.Expiration <= new Date().getTime() )
        response = resp( 407 );
    else
        response = resp( 100 );

    if( response.statusCode >= 400 ) {
        policy.denyAllMethods();
        const authResponse = policy.build();
        authResponse.context = response;
        cb( null, authResponse );
    } else {
        COG.getCredentialsForIdentity( {
            IdentityId: item.data.IdentityId,
            Logins: {
                'cognito-identity.amazonaws.com': item.data.Token
            }
        }, ( e, d ) => {
            if( e ) {
                policy.denyAllMethods();
                response = resp( 401 );
            } else {
                policy.allowMethod( AuthPolicy.HttpVerb.GET, "/user" );
                policy.allowMethod( AuthPolicy.HttpVerb.DELETE, "/user" );
                response = resp( 202 );
            }

            const authResponse = policy.build();
            authResponse.context = response;
            cb( null, authResponse );
        } );
    }
};

Above is the full example... But let me break this down and explain why the one they provide is not as helpful.

Here are the steps to setting this up so you can see why it has to be something like this.

  1. Go to Lambda and make a function called Auth_isValid or something like that
  2. Put your PoolId and principalId into the Environment Variables so it's easy to change later
  3. Head over to API Gateway and lets link this up
  4. Under API Options on the left side, hit Authorizers
  5. Click Create -> Custom Authorizer
  6. Fill in your Lambda Region, function name (should auto-fill), Authorizer name, Identity Token Source (keep it simple with method.request.header.Authorization for now, and TTL can be 300. Lets not mess with Execution role or token validation expression yet.
  7. Save/Update it and head back to Lambda - we'll hook up a function with this authorizer later.

Ok so when you look at my function, you'll see that I do this weird encrypt/decrypt thing at the very top:

token  = {
    algorithm: "aes-256-ctr",
    encrypt: item => {
        item = JSON.stringify( item );
        let cipher = crypto.createCipher( token.algorithm, process.env.PoolId ),
            crypted = cipher.update( item, 'utf8', 'base64' );
        crypted += cipher.final( 'base64' );
        return crypted;
    },
    decrypt: item => {
        let decipher = crypto.createDecipher( token.algorithm, process.env.PoolId ),
            dec = decipher.update( item, 'base64', 'utf8' );
        dec += decipher.final( 'utf8' );
        return dec;
    }
};

Basically, I wrap some items I want inside an encrypted key simple so I can pass all my information around easy-peasy. (I pass in the Identity Pool as a hash to make it cool and simple and as long as you never send the Identity Pool ID to the front end, we're good!)

The Custom Authorizer requires one single token, not a JSON block of what you'll say is a "token" or something (which you could do but it looks dumb)

So we have one unified token that gets passed in and I call the decrypt function for this to unwrap (I'll show the encrypt example in a second.

Now some people may say "oh well that's not actually encryption it could easily be figured out" - my answer to this is: "ya well it would have been unencrypted, raw text anyway, why not make it easy."

Ok now that you see that part, head down to the bottom of the function.

let response;

if( !event.authorizationToken || typeof event.authorizationToken !== "string" )
    response = resp( 401 );

let item = token.decrypt( event.authorizationToken );

try { item = resp( 100, JSON.parse( item ) ); }
catch( e ) { item = resp( 401 ); }

if( item.statusCode !== 100 )
    response = resp( 401 );
else if( item.data.Expiration <= new Date().getTime() )
    response = resp( 407 );
else
    response = resp( 100 );

if( response.statusCode >= 400 ) {
    policy.denyAllMethods();
    const authResponse = policy.build();
    authResponse.context = response;
    cb( null, authResponse );
} else {
    COG.getCredentialsForIdentity( {
        IdentityId: item.data.IdentityId,
        Logins: {
            'cognito-identity.amazonaws.com': item.data.Token
        }
    }, ( e, d ) => {
        if( e ) {
            policy.denyAllMethods();
            response = resp( 401 );
        } else {
            policy.allowMethod( AuthPolicy.HttpVerb.GET, "/user" );
            policy.allowMethod( AuthPolicy.HttpVerb.DELETE, "/user" );
            response = resp( 202 );
        }

        const authResponse = policy.build();
        authResponse.context = response;
        cb( null, authResponse );
    } );
}

Update:

Our incoming data from API Gateway is:

{
    "type":"TOKEN",
    "authorizationToken":"<session_token>",
    "methodArn":"arn:aws:execute-api:<region>:<Account_ID>:<API_ID>/<Stage>/<Method>/<Resource_Path>"
}

Our outgoing data from Lambda should be something like:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "execute-api:Invoke",
            "Effect": "Deny",
            "Resource": [
                "arn:aws:execute-api:<region>:<Account_ID>:<API_ID>/<Stage>/*/*"
            ]
        }
    ]
}

Depending on how our authorization goes.


So in my first if check, I make sure the authorizationToken is there and that it's a string, if it's not, we say it's Unauthorized (everyone should know and use their status codes)

Second, I decrypt the token and make sure that went well with a try-catch attempt. If it didn't go well, they're Unauthorized. if it did, we can Continue.

You'll see in the token, I put a variable Expiration, this is how I check if the key was once accepted and correct and is simply expired now. For this, I say Proxy Authentication Required. Which tells my front end, go call login again and give me new creds. Don't forget, the purpose of this function has to be only to check IF we're authorized. Not to do fancy things like refresh tokens.

Next, I check if everything is good and call denyAllMethods and put the response code in the context of the response. API Gateway is very picky and only wants simply IAM formatted policies passed around - no other information or format or whatever may be in there if it's not specified HERE or HERE

If everything is OK, I call getCredentialsForIdentity - using the IdentityId and Token, make sure that token is, in fact valid as well, and then I allow the functions needed at the time. These are very important and will validate the token to only those functions - in other words. If your IAM role in IAM says it can access everything, this will say no, you can only access GET on /user and DELETE on /user. So don't let it fool you. This is a custom authorizer after all.

Next, I need to show you how I put all this in from the Login part. I have the same token = { part but in my login function I added a getToken function:

token.getToken = obj => {
    return new Promise( ( res, rej ) => {
        COG.getOpenIdTokenForDeveloperIdentity( {
            IdentityPoolId: process.env.PoolId,
            Logins: {
                "com.whatever.developerIdthing": obj.email
            },
            TokenDuration: duration
        }, ( e, r ) => {
            r.Expiration = new Date().getTime() + ( duration * 1000 );
            if( e ) rej( e );
            else res( token.encrypt( r ) );
        } );
    } );
};

Notice above, the:

duration

Part.

This is the answer to your second question:

How long does the authentication last? I want the user to remain logged in. This is how most apps I use work and stays logged in until they hit logout.

You create an OpenIdToken using their email or whatever you want to identify them and TokenDuration is in seconds. I would recommend making this a week or two but if you wanted a year long or something, 31536000 would be it. Another way of doing this is to make a function that only gives you authorized credentials, and instead of calling denyAll in the authorizer when a 407 scenario comes up, make the only method they can call allowMethod( POST, /updateCreds ); or something like that. This way you can refresh their stuff every once in a while.

The pseudo for that is:

Remove:

if( response.statusCode >= 400 )
else

And do:

if( statusCode >= 400 )
    denyAll
else if( statusCode === 407 )
    allow refresh function
else
    allow everything else

Hope this helps!

Source Show
◀ Wstecz