aws-amplify / aws-sdk-ios

AWS SDK for iOS. For more information, see our web site:
https://aws-amplify.github.io/docs
Other
1.68k stars 885 forks source link

OTP based login using Cognito custom authentication #1951

Closed anees17861 closed 4 years ago

anees17861 commented 5 years ago

State your question How to use Cognito iOS SDK for custom Authentication?

I have followed AWS re:Invent 2016: Add User Sign-In, User Management, and Security with Amazon Cognito (MBL310) to setup the lambdas.

I'm able to successfully receive the sms and login using android sdk but not sure how the process works in iOS

I've attached the code below. Here the startCustomAuthentication gets called properly and right after that getCustomChallengeDetails is called as well. But the authentication input returns empty. In android when the same callback is called the cloud has done sending the sms and all I have to is set the result to the continuation once the user enters it

I observed that the DefineAuthChallenge trigger is never fired in case of iOS and thus the sms is never sent

One more thing I observed is if I set an empty result in getCustomChallengeDetails (commented in the code) it sends the sms but startCustomAuthentication gets called again making it fall into an infinite loop.

Which AWS Services are you utilizing? Cognito Lambda

Provide code snippets (if applicable)

Call to initiate custom auth

self.pool = AWSCognitoIdentityUserPool(forKey: AWSCognitoUserPoolsSignInProviderKey)
self.pool?.delegate = self
self.user = self.pool?.getUser(username!)
self.user?.getSession()

Interactive Authentication delegate

extension LoginVC : AWSCognitoIdentityInteractiveAuthenticationDelegate {
    func startCustomAuthentication() -> AWSCognitoIdentityCustomAuthentication {
        print("starting custom auth for user : \(self.user?.username ?? "nil")")
        if let otpVC = self.presentedViewController as? OTPViewController {
            return verifyVC
        }
        let otpVC = OTPViewController.init(nibName: "OTPViewController", bundle: nil)
        otpVC.user = self.user
        DispatchQueue.main.async {
            self.present(otpVC, animated: true, completion: nil)
        }
        return otpVC
    }
}

Custom Auth delegate

extension OTPViewController : AWSCognitoIdentityCustomAuthentication {
    func getCustomChallengeDetails(_ authenticationInput: AWSCognitoIdentityCustomAuthenticationInput, customAuthCompletionSource: AWSTaskCompletionSource<AWSCognitoIdentityCustomChallengeDetails>) {
        self.otpLoginContinuation = customAuthCompletionSource
        print(authenticationInput.challengeParameters) //Gives empty dictionary
            //customAuthCompletionSource.set(result: AWSCognitoIdentityCustomChallengeDetails()) //if result is set with empty response sms is sent but startCustomAuthentication gets called again making it fall into an infinite loop

    }

    func didCompleteStepWithError(_ error: Error?) {
        if let error = error as NSError? {
            print("error : \(error)")
        } else {
            print("success")
        }
    }
}

Once user enters the OTP

self.otpLoginContinuation?.set(result:
                        AWSCognitoIdentityCustomChallengeDetails.init(challengeResponses: [
                            "USERNAME" : self.user!.username!,
                            "ANSWER" : otpString
                        ]))

Environment(please complete the following information):

Device Information (please complete the following information):

lawmicha commented 5 years ago

Hi @anees17861

Thanks for detailed explanation of your issue, just a quick note before we dive further, we are currently working on custom auth with AWSMobileClient and will be releasing that soon (in PR here https://github.com/aws-amplify/aws-sdk-ios/pull/1860 ). Looping in @royjit to see if he can identify that this would work for your use case as he has some more context on custom auth.

royjit commented 5 years ago

Yes, looks like you can use AWSMobileClient with custom authentication. This should be available in the next release.

What is the sequence of your auth flow? Do you have username/password as the first flow? Can you share your Define Auth Challenge lambda trigger?

anees17861 commented 5 years ago

I have something similar to Amazon app login. User can login with either phone number/password or with otp. For password flow I call getsession with username and password. For otp flow I set a delegate and call getsession without any parameters.

As for define auth challenge, I don't have access to a desktop right now, so I'll share it later. But it's a word to word copy of the trigger provided in the aws reinvent video (link in previous comment)

anees17861 commented 5 years ago

Define auth challenge

exports.handler = function (event,context,callback) {
    console.log('EVENT request', event.request);
    // TODO implement
    if(event.request.session.length === 0){
        event.response.issueTokens = false;
        event.response.failAuthentication = false;
        event.response.challengeName = 'CUSTOM_CHALLENGE';
    }
    else if(event.request.session.length === 1
        && event.request.session[0].challengeName === 'CUSTOM_CHALLENGE'
        && event.request.session[0].challengeResult === true){
        event.response.issueTokens = true;
        event.response.failAuthentication = false;
    }
    else {
        event.response.issueTokens = false;
        event.response.failAuthentication = true;
    }
    console.log('EVENT response', event.response);
    callback(null,event);
};

Create auth challenge

var AWS = require('aws-sdk');

exports.handler = function (event,context,callback) {
    console.log('EVENT request', event.request);
    // TODO implement
    if(event.request.session.length === 0
        &&  event.request.challengeName === 'CUSTOM_CHALLENGE' ){
        //Create a otp
        var answer = Math.random().toString(10).substr(2,6);

        //sns sms
        var sns = new AWS.SNS({region:'ap-southeast-1'});
        sns.publish({
            Message: 'your otp: '+answer,
            PhoneNumber: event.request.userAttributes.phone_number
        },function(err,data){
           if(err){
               console.log(err.stack);
               return;
           }
           console.log(`SMS sent to ${event.request.userAttributes.phone_number} and otp = ${answer}`);
        });

        //set return params
        event.response.publicChallengeParameters = {};
        event.response.privateChallengeParameters = {};
        event.response.privateChallengeParameters.answer = answer;
        event.response.challengeMetadata = 'PASSWORDLESS_CHALLENGE';
    }
    console.log('EVENT response', event.response);
    callback(null,event);
};

Verify Auth Challenge

exports.handler = function (event,context,callback) {
    console.log(`EVENT request`,event.request);
    // TODO implement
    event.response.answerCorrect = (event.request.privateChallengeParameters.answer === event.request.challengeAnswer);
    console.log(`isAnswerCorrect = ${event.response.answerCorrect}`)
    if(!event.response.answerCorrect){
        console.log(`Correct answer = ${event.request.privateChallengeParameters.answer}`);
        console.log(`Given answer = ${event.request.challengeAnswer}`);
    }
    console.log(`EVENT response`,event.response);
    callback(null,event);
};

Sorry for the delay, got occupied elsewhere

royjit commented 5 years ago

@anees17861 Thank you for providing the details. I was able to make this work by returning an empty value for the first invocation of getCustomChallengeDetails and on the second invocation pass the challenge response to the continuation.

if (authenticationInput.challengeParameters.count > 0) {
            let details = AWSCognitoIdentityCustomChallengeDetails(challengeResponses: ["ANSWER" : "1133"])
            customAuthCompletionSource.set(result: details)
 } else {
            let details = AWSCognitoIdentityCustomChallengeDetails()
            customAuthCompletionSource.set(result: details)
}    

Can you please check if this works for you?

anees17861 commented 5 years ago
self.otpLoginContinuation = customAuthCompletionSource
        if(authenticationInput.challengeParameters.count == 0) {
            print("sending user name")
            customAuthCompletionSource.set(result: AWSCognitoIdentityCustomChallengeDetails())
        }

Currently I am doing this since I need to wait for the user to enter the otp. It works fine. But is this the expected way?

Also how should I handle didCompleteStepWithError(_ error : Error?)? It gets fired in both cases. I have to give feedback to the user if otp was success. The only way I can think of is simply maintaining a boolean flag.

royjit commented 5 years ago

Unfortunately this is how the SDK is setup and I cannot find an easy way to handle this. One work around is to listen to the getSession() task and handle success:

self.currentUser?.getSession().continueWith(block: { (task) -> Any? in
                            if let session = task.result {
                                // Auth completed.
                            } 
                            return nil
 })

We will take this up as a feature request and will update here when we implement this.

anees17861 commented 5 years ago

Hi,

Thanks for all the help. The login functionality is working fine. But there is another issue I'm facing now.

I'm using Cognito to sign network api calls. To achieve this I'm calling getsession before every api call. This causes the delegate to fire up again. And I end up receiving multiple otps. I tried to set delegate to nil on continueWith() but that was not possible since it's not an optional. How should I solve this. Also if there is a better way to sign the api than calling get session please let me know

dholdren commented 5 years ago

@royjit does AWSMobileClient work with this type of custom auth (passwordless) ?

royjit commented 4 years ago

Apologies for the delayed response

@anees17861 getCustomChallengeDetails delegate method will be fired only when the refresh token is expired and the user has to sign In again. If you are still seeing this problem please open a new issue with details and we will investigate.

@dholdren Yes, AWSMobileClient supports custom auth. You can find more details here.

stale[bot] commented 4 years ago

This issue has been automatically closed because of inactivity. Please open a new issue if are still encountering problems.

Purnachndar commented 4 years ago

Hi @royjit,

I'm also facing the same issue that @anees17861 was facing.

So, My log in functionality is working fine but receiving multiple OTPs. Can you please help how to solve this.

@anees17861 Could you please help me if how you solved the multiple OTPs issue??. Thanks in advance :)

My code:

CreateAuthChallenge

const AWS = require('aws-sdk'); exports.handler = (event, context, callback) => { //Create a random number for otp const challengeAnswer = Math.random().toString(10).substr(2, 6); const phoneNumber = event.request.userAttributes.phone_number; //sns sms const sns = new AWS.SNS({ region: 'us-east-1' }); sns.publish( { Message: 'your otp: ' + challengeAnswer, PhoneNumber: phoneNumber, MessageStructure: 'string', MessageAttributes: { 'AWS.SNS.SMS.SenderID': { DataType: 'String', StringValue: 'AMPLIFY', }, 'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: 'Transactional', }, }, }, function (err, data) { if (err) { console.log(err.stack); console.log(data); return; } console.log("Data In Create", data); return data; } ); //set return params event.response.privateChallengeParameters = {}; event.response.privateChallengeParameters.answer = challengeAnswer; event.response.challengeMetadata = 'CUSTOM_CHALLENGE'; callback(null, event); };

Define Auth Challenge

exports.handler = (event, context) => { if (event.request.session.length === 0) { event.response.issueTokens = false; event.response.failAuthentication = false; event.response.challengeName = 'CUSTOM_CHALLENGE'; } else if ( event.request.session.length === 1 && event.request.session[0].challengeName === 'CUSTOM_CHALLENGE' && event.request.session[0].challengeResult === true ) { event.response.issueTokens = true; event.response.failAuthentication = false; } else { event.response.issueTokens = false; event.response.failAuthentication = true; } context.done(null, event); };

Pre sign-up Function

exports.handler = (event, context, callback) => { // Confirm the user event.response.autoConfirmUser = true; // Set the email as verified if it is in the request if (event.request.userAttributes.hasOwnProperty('email')) { event.response.autoVerifyEmail = true; } // Set the phone number as verified if it is in the request if (event.request.userAttributes.hasOwnProperty('phone_number')) { event.response.autoVerifyPhone = true; } // Return to Amazon Cognito callback(null, event); };

Verify function

exports.handler = (event, context) => { if (event.request.privateChallengeParameters.answer === event.request.challengeAnswer) { event.response.answerCorrect = true; } else { event.response.answerCorrect = false; } context.done(null, event); };

anees17861 commented 4 years ago

@Purnachndar In my case problem was on iOS side. Can you verify wether your lanbda triggers properly when done through Android? The sdk in Android is pretty stable and straightforward for custom auth.

Purnachndar commented 4 years ago

@Purnachndar In my case problem was on iOS side. Can you verify wether your lanbda triggers properly when done through Android? The sdk in Android is pretty stable and straightforward for custom auth.

Thanks for the reply. The thing is we are building the app in react which will be using for both android, IOS, and web as well. Btw my lambda triggers are working fine but I'm receiving multiple OTPs once I give a user phone number to sign in.

anees17861 commented 4 years ago

@Purnachndar Sorry to say but in my case issue was purely on using native iOS cognito sdk. By react I'm guessing you are using the amplify library which I've not yet gotten around to use.

What happened in iOS was there was a function called getcustomauthchallenge. In Android this function was called only after create auth challenge trigger sent otp which made it straightforward to use. In case of iOS it was called twice. At first it was called independently with no challenge parameters. At this point i had to set response with default object which actually caused the OTP to be sent. After sending, the getcustomauthchallenge was called again making it fall into an infinite loop. I fixed this by making a check for challenge parameters count. If 0 means it was called first time by the sdk. If greater than 0, it was called after sending OTP and hence nothing to do but wait until user enters OTP.

I've not gone through the docs of amplify react so I'm not sure about the callbacks, but if similar then problem could be same.