From 83031adee23cd74ab6fa9c6d09ce6b286a04e4f0 Mon Sep 17 00:00:00 2001 From: Bariq Date: Wed, 6 Oct 2021 11:34:34 +0800 Subject: [PATCH 1/5] create api for otp functions Signed-off-by: Bariq --- gotrue/api.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/gotrue/api.py b/gotrue/api.py index d591c27a..1367df68 100644 --- a/gotrue/api.py +++ b/gotrue/api.py @@ -41,6 +41,53 @@ def sign_up_with_email(self, email: str, password: str) -> Dict[str, Any]: ) return to_dict(request) + def send_mobile_otp(self, phone: str) -> Dict[str, Any]: + """Sends a mobile OTP via SMS. Will register the account if it doesn't already exist + + Parameters + ---------- + phone : str + The user's phone number WITH international prefix. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. + """ + credentials = {"phone": phone} + request = requests.post( + f"{self.url}/otp", json.dumps(credentials), headers=self.headers + ) + return to_dict(request) + + def verify_mobile_otp(self, phone: str, token: str, options: Dict[str, Any]) -> Dict[str, Any]: + """Send User supplied Mobile OTP to be verified + + Parameters + ---------- + phone : str + The user's phone number WITH international prefix + token : str + Token that user was sent to their mobile phone + options : dict of any + A URL or mobile address to send the user to after they are confirmed. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. + """ + payload = { + "phone": phone, + "token": token, + "type": "sms", + "redirect_to": options.get("redirect_to") + } + request = requests.post( + f"{self.url}/verify", json.dumps(payload), headers=self.headers + ) + return to_dict(request) + def sign_in_with_email(self, email: str, password: str) -> Dict[str, Any]: """Logs in an existing user using their email address. From 68f699bf28a86ff892de36fac4af2259f2733862 Mon Sep 17 00:00:00 2001 From: Bariq Date: Wed, 6 Oct 2021 11:34:46 +0800 Subject: [PATCH 2/5] create client for otp functions Signed-off-by: Bariq --- gotrue/client.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/gotrue/client.py b/gotrue/client.py index 2ec827be..64792803 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -84,6 +84,7 @@ def sign_in( email: Optional[str] = None, password: Optional[str] = None, provider: Optional[str] = None, + phone: Optional[str] = None, ) -> Dict[str, Any]: """Log in an exisiting user, or login via a third-party provider.""" self._remove_session() @@ -91,10 +92,34 @@ def sign_in( data = self.api.send_magic_link_email(email) elif email is not None and password is not None: data = self._handle_email_sign_in(email, password) + elif phone is not None and password is None: + data = self.api.send_mobile_otp(phone) elif provider is not None: data = self._handle_provider_sign_in(provider) else: - raise ValueError("Email or provider must be defined, both can't be None.") + raise ValueError("Email, provider, or phone must be defined, all can't be None.") + return data + + def verify_otp( + self, + phone: str, + token: str, + options: Optional[Dict[str, Any]] = {}, + ) -> Dict[str, Any]: + """Log in a user given a User supplied OTP received via mobile.""" + self._remove_session() + + if options is None: + options = {} + + data = self.api.verify_mobile_otp(phone, token, options) + + if "access_token" in data: + session = data + + self._save_session(session) + self._notify_all_subscribers('SIGNED _IN') + return data def user(self) -> Optional[Dict[str, Any]]: From 1870b544435ecc05da61965f4dfbe5dd4732126c Mon Sep 17 00:00:00 2001 From: Bariq Date: Fri, 8 Oct 2021 01:11:32 +0800 Subject: [PATCH 3/5] create new api for signup with phone Signed-off-by: Bariq --- gotrue/api.py | 27 ++++++++++++++++++++++++--- gotrue/client.py | 15 +++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/gotrue/api.py b/gotrue/api.py index 1367df68..6eb1aac3 100644 --- a/gotrue/api.py +++ b/gotrue/api.py @@ -41,6 +41,27 @@ def sign_up_with_email(self, email: str, password: str) -> Dict[str, Any]: ) return to_dict(request) + def sign_up_with_phone(self, phone: str, password: str) -> Dict[str, Any]: + """Creates a new user using their phone number + + Parameters + ---------- + phone : str + The user's phone number. + password : str + The user's password. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. + """ + credentials = {"phone": phone, "password": password} + request = requests.post( + f"{self.url}/signup", json.dumps(credentials), headers=self.headers + ) + return to_dict(request) + def send_mobile_otp(self, phone: str) -> Dict[str, Any]: """Sends a mobile OTP via SMS. Will register the account if it doesn't already exist @@ -78,9 +99,9 @@ def verify_mobile_otp(self, phone: str, token: str, options: Dict[str, Any]) -> The user or error message returned by the supabase backend. """ payload = { - "phone": phone, - "token": token, - "type": "sms", + "phone": phone, + "token": token, + "type": "sms", "redirect_to": options.get("redirect_to") } request = requests.post( diff --git a/gotrue/client.py b/gotrue/client.py index 64792803..9314dd5e 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -60,7 +60,7 @@ def __init__( self.api = GoTrueApi(url=url, headers=headers, cookie_options=cookie_options) self._recover_session() - def sign_up(self, email: str, password: str): + def sign_up(self, password: str, phone: Optional[str], email: Optional[str]): """Creates a new user. Parameters @@ -71,7 +71,14 @@ def sign_up(self, email: str, password: str): The user's password. """ self._remove_session() - data = self.api.sign_up_with_email(email, password) + + if email and password: + data = self.api.sign_up_with_email(email, password) + elif phone and password: + data = self.api.sign_up_with_phone(phone, password) + else: + raise ValueError("Email or phone must be defined, both can't be None.") + if "expires_in" in data and "user" in data: # The user has confirmed their email or the underlying DB doesn't # require email confirmation. @@ -111,13 +118,13 @@ def verify_otp( if options is None: options = {} - + data = self.api.verify_mobile_otp(phone, token, options) if "access_token" in data: session = data - self._save_session(session) + self._save_session(session) self._notify_all_subscribers('SIGNED _IN') return data From 7162a472194fc59ae3647bd89722df50cb8ea72a Mon Sep 17 00:00:00 2001 From: Bariq Date: Fri, 8 Oct 2021 01:12:16 +0800 Subject: [PATCH 4/5] add new tests for signup by phone and verify otp Signed-off-by: Bariq --- tests/test_gotrue.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index d6ab4129..de55defe 100644 --- a/tests/test_gotrue.py +++ b/tests/test_gotrue.py @@ -14,6 +14,15 @@ def _random_string(length: int = 10) -> str: random.choices(string.ascii_uppercase + string.digits, k=length)) +def _random_phone_number() -> str: + first = str(random.randint(100, 999)) + second = str(random.randint(1, 888)).zfill(3) + last = (str(random.randint(1, 9998)).zfill(4)) + while last in ['1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888']: + last = (str(random.randint(1, 9998)).zfill(4)) + return '{}-{}-{}'.format(first, second, last) + + def _assert_authenticated_user(data: Dict[str, Any]): """Raise assertion error if user is not logged in correctly.""" assert "access_token" in data @@ -114,3 +123,42 @@ def test_set_auth(client: Client): client.set_auth(mock_access_token) new_session = client.session() assert new_session["access_token"] == mock_access_token + + +def test_sign_up_phone_password(client: Client): + """Test client can sign up with phone and password""" + random_phone: str = _random_phone_number() + random_password: str = _random_string(20) + data = client.sign_up(phone=random_phone, password=random_password) + _assert_authenticated_user(data) + assert client.current_user is not None + assert client.current_session is not None + + assert "id" in data and isinstance(data.get('id'), str) + assert "created_at" in data and isinstance(data.get('created_at'), str) + assert "email" in data and data.get('email') == '' + assert "confirmation_sent_at" in data and isinstance( + data.get('confirmation_sent_at'), str) + assert "phone" in data and data.get('phone') == random_phone + assert "aud" in data and isinstance(data.get('aud'), str) + assert "updated_at" in data and isinstance(data.get('updated_at'), str) + assert "app_metadata" in data and isinstance( + data.get('app_metadata'), dict) + assert "provider" in data.get( + 'app_metadata') and data['app_metadata'].get('provider') == 'phone' + assert "user_metadata" in data and isinstance(data.get('id'), dict) + assert "status" in data.get( + 'user_metadata') and data['user_metadata'].get('status') == 'alpha' + + +def test_verify_mobile_otp(client: Client): + """Test client can sign up with phone and password""" + random_token: str = '123456' + random_phone: str = _random_phone_number() + data = client.verify_otp(phone=random_phone, token=random_token) + + assert "session" in data and data['session'] is None + assert "user" in data and data['user'] is None + assert "error" in data + assert "message" in data['error'] + assert "Otp has expired or is invalid" in data['error']['message'] From 202346a62940c55883e24b4efd1604e690a9e9a6 Mon Sep 17 00:00:00 2001 From: Bariq Date: Fri, 8 Oct 2021 01:14:10 +0800 Subject: [PATCH 5/5] fix comments Signed-off-by: Bariq --- tests/test_gotrue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index de55defe..497a4ac7 100644 --- a/tests/test_gotrue.py +++ b/tests/test_gotrue.py @@ -152,7 +152,7 @@ def test_sign_up_phone_password(client: Client): def test_verify_mobile_otp(client: Client): - """Test client can sign up with phone and password""" + """Test client can verify their mobile using OTP""" random_token: str = '123456' random_phone: str = _random_phone_number() data = client.verify_otp(phone=random_phone, token=random_token)