diff --git a/gotrue/api.py b/gotrue/api.py index d591c27a..6eb1aac3 100644 --- a/gotrue/api.py +++ b/gotrue/api.py @@ -41,6 +41,74 @@ 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 + + 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. diff --git a/gotrue/client.py b/gotrue/client.py index 2ec827be..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. @@ -84,6 +91,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 +99,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]]: diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index d6ab4129..497a4ac7 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 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) + + 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']