From 5df38f685d04fd2d30ec39bbd7f4d7e461197a8e Mon Sep 17 00:00:00 2001 From: Anas Yousef <44998563+anas-yousef@users.noreply.github.com> Date: Wed, 17 Apr 2024 09:30:24 +0300 Subject: [PATCH 1/6] Added repo to handle auth logic --- lib/server_exception.dart | 25 +++++++++ lib/src/auth/auth_repo.dart | 108 ++++++++++++++++++++++++++++++++++++ pubspec.yaml | 1 + routes/api/v1/otp/send.dart | 6 ++ 4 files changed, 140 insertions(+) create mode 100644 lib/server_exception.dart create mode 100644 lib/src/auth/auth_repo.dart create mode 100644 routes/api/v1/otp/send.dart diff --git a/lib/server_exception.dart b/lib/server_exception.dart new file mode 100644 index 0000000..cf48788 --- /dev/null +++ b/lib/server_exception.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +/// A general exception class used to handle error messages on the server side +class ServerException implements Exception { + /// Constructor + ServerException({ + required this.errorMessage, + String? errorCode, + this.errorBody, + }) : errorCode = errorCode ?? HttpStatus.internalServerError.toString(); + + /// The error message received + final String errorMessage; + + /// Error code of exception + final String errorCode; + + /// An error body if available + final Map? errorBody; + + @override + String toString() { + return 'ServerException -> $errorMessage'; + } +} diff --git a/lib/src/auth/auth_repo.dart b/lib/src/auth/auth_repo.dart new file mode 100644 index 0000000..f7c744f --- /dev/null +++ b/lib/src/auth/auth_repo.dart @@ -0,0 +1,108 @@ +import 'package:supabase/supabase.dart'; +import 'package:touch_cut_server/server_exception.dart'; + +/// An exception class for the authentication repository +class AuthRepoException extends ServerException { + /// Constructor for the AuthRepoException class + AuthRepoException({ + required String errorMessage, + super.errorCode, + super.errorBody, + }) : super( + errorMessage: 'AuthRepoException -> $errorMessage', + ); +} + +class AuthRepo { + /// Constructor + const AuthRepo({required this.supabaseClient}); + + /// The supabase client + final SupabaseClient supabaseClient; + + /// Send OTP to user + Future sendOtpToUser( + String phoneNumber, + ) async { + try { + await supabaseClient.auth.signInWithOtp( + phone: phoneNumber, + ); + } catch (err) { + throw AuthRepoException( + errorMessage: err.toString(), + ); + } + } + + /// Verify the token and return the user + Future verifyAccessToken({ + required String accessToken, + }) async { + try { + final userResponse = await supabaseClient.auth.getUser(accessToken); + print("Got the user $userResponse"); + return userResponse.user; + } on AuthException catch (authException) { + print(authException); + // We will have a wrapper that sends a 401 status code if the user is null + return null; + } catch (err) { + throw AuthRepoException( + errorMessage: err.toString(), + ); + } + } + + /// Retrieve token and refresh token + Future> verifyAndLoginUserOTP({ + required String phoneNumber, + required String token, + }) async { + try { + final authResponse = await supabaseClient.auth.verifyOTP( + type: OtpType.sms, + token: token, + phone: phoneNumber, + ); + final session = authResponse.session!; + return { + 'access_token': session.accessToken, + 'refresh_token': session.refreshToken!, + }; + } on AuthException catch (authException) { + print(authException); + throw AuthRepoException( + errorCode: authException.statusCode, + errorMessage: authException.toString(), + ); + } catch (err) { + throw AuthRepoException( + errorMessage: err.toString(), + ); + } + } + + /// Refresh access and refresh token + Future> refreshAccessToken( + String refreshToken, + ) async { + try { + final authResponse = await supabaseClient.auth.setSession(refreshToken); + final session = authResponse.session!; + return { + 'access_token': session.accessToken, + 'refresh_token': session.refreshToken!, + }; + } on AuthException catch (authException) { + throw AuthRepoException( + errorCode: authException.statusCode, + errorMessage: authException.toString(), + ); + } catch (err) { + throw AuthRepoException( + errorMessage: err.toString(), + ); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8808224..79a0657 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: dart_frog: ^1.0.0 + supabase: ^2.1.1 dev_dependencies: mocktail: ^1.0.0 diff --git a/routes/api/v1/otp/send.dart b/routes/api/v1/otp/send.dart new file mode 100644 index 0000000..0e823cf --- /dev/null +++ b/routes/api/v1/otp/send.dart @@ -0,0 +1,6 @@ +import 'package:dart_frog/dart_frog.dart'; + +Response onRequest(RequestContext context) { + // TODO: implement route handler + return Response(body: 'This is a new route!'); +} From bbd1daf2678abc8a6ff6c4c7b383c6d283783685 Mon Sep 17 00:00:00 2001 From: Anas Yousef <44998563+anas-yousef@users.noreply.github.com> Date: Wed, 17 Apr 2024 18:08:35 +0300 Subject: [PATCH 2/6] Added .env to gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 67dfe64..628be94 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ build/ .dart_frog # Test related files -coverage/ \ No newline at end of file +coverage/ + +# Environment files +.env \ No newline at end of file From e12cf6c0e70bf6de4ac7eb7c9c7119e6b84f3493 Mon Sep 17 00:00:00 2001 From: Anas Yousef <44998563+anas-yousef@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:48:00 +0300 Subject: [PATCH 3/6] Added signing in and up endpoint --- .git-blame-ignore-revs | 0 lib/server_exception.dart | 6 +- lib/src/auth/auth_repo.dart | 70 ++++++++++++++++++--- main.dart | 19 ++++++ pubspec.yaml | 2 + routes/_middleware.dart | 17 ++++++ routes/api/auth/otp/send.dart | 104 ++++++++++++++++++++++++++++++++ routes/api/auth/otp/verify.dart | 50 +++++++++++++++ routes/api/auth/refresh.dart | 47 +++++++++++++++ routes/api/v1/_middleware.dart | 17 ++++++ routes/api/v1/otp/send.dart | 6 -- routes/api/v1/signout.dart | 6 ++ 12 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 .git-blame-ignore-revs create mode 100644 main.dart create mode 100644 routes/_middleware.dart create mode 100644 routes/api/auth/otp/send.dart create mode 100644 routes/api/auth/otp/verify.dart create mode 100644 routes/api/auth/refresh.dart create mode 100644 routes/api/v1/_middleware.dart delete mode 100644 routes/api/v1/otp/send.dart create mode 100644 routes/api/v1/signout.dart diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e69de29 diff --git a/lib/server_exception.dart b/lib/server_exception.dart index cf48788..56a82e7 100644 --- a/lib/server_exception.dart +++ b/lib/server_exception.dart @@ -7,13 +7,15 @@ class ServerException implements Exception { required this.errorMessage, String? errorCode, this.errorBody, - }) : errorCode = errorCode ?? HttpStatus.internalServerError.toString(); + }) : errorCode = errorCode != null + ? int.parse(errorCode) + : HttpStatus.internalServerError; /// The error message received final String errorMessage; /// Error code of exception - final String errorCode; + final int errorCode; /// An error body if available final Map? errorBody; diff --git a/lib/src/auth/auth_repo.dart b/lib/src/auth/auth_repo.dart index f7c744f..e1a8927 100644 --- a/lib/src/auth/auth_repo.dart +++ b/lib/src/auth/auth_repo.dart @@ -21,13 +21,44 @@ class AuthRepo { final SupabaseClient supabaseClient; /// Send OTP to user - Future sendOtpToUser( + Future sendOtpToUser({ + required String phoneNumber, + Map? data, + }) async { + try { + await supabaseClient.auth.signInWithOtp( + phone: phoneNumber, + data: data, + ); + } on AuthException catch (authException) { + print(authException); + throw AuthRepoException( + errorCode: authException.statusCode, + errorMessage: authException.toString(), + ); + } catch (err) { + throw AuthRepoException( + errorMessage: err.toString(), + ); + } + } + + /// Resend OTP to user + Future resendOtpToUser( String phoneNumber, ) async { try { - await supabaseClient.auth.signInWithOtp( + final resendResponse = await supabaseClient.auth.resend( + type: OtpType.sms, phone: phoneNumber, ); + // print(resendResponse.messageId); + } on AuthException catch (authException) { + print(authException); + throw AuthRepoException( + errorCode: authException.statusCode, + errorMessage: authException.toString(), + ); } catch (err) { throw AuthRepoException( errorMessage: err.toString(), @@ -54,8 +85,10 @@ class AuthRepo { } } - /// Retrieve token and refresh token - Future> verifyAndLoginUserOTP({ + /// Verifies the OTP supplied by the user, and returns + /// the access and refresh token, after creating a session + /// for the user + Future> verifyOTP({ required String phoneNumber, required String token, }) async { @@ -84,9 +117,9 @@ class AuthRepo { } /// Refresh access and refresh token - Future> refreshAccessToken( - String refreshToken, - ) async { + Future> refreshAccessToken({ + required String refreshToken, + }) async { try { final authResponse = await supabaseClient.auth.setSession(refreshToken); final session = authResponse.session!; @@ -105,4 +138,27 @@ class AuthRepo { ); } } + + /// This function checks if the supplied phone number from the user is + /// in the DB, while trying to sign in. If not found, that means no user + /// exits with that phone number, therefore, we will return an error asking + /// the user to sign-up, if found, then we will go ahead and send the user + /// an SMS containing an OTP. + Future phoneNumberExists({required String phoneNumber}) async { + // Supabase DB saves the phone number without the + sign + var phoneNumberToCheck = phoneNumber; + if (phoneNumber.startsWith('+')) { + phoneNumberToCheck = phoneNumber.substring(1); + } + final data = await supabaseClient + .from('users') + .select() + .eq('phone_number', phoneNumberToCheck); + if (data.isEmpty) { + print('$phoneNumber not found in the system'); + return false; + } + print('$phoneNumber found in the system'); + return true; + } } diff --git a/main.dart b/main.dart new file mode 100644 index 0000000..9df581a --- /dev/null +++ b/main.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:supabase/supabase.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +late AuthRepo authRepo; + +Future run(Handler handler, InternetAddress ip, int port) { + final env = DotEnv(includePlatformEnvironment: true)..load(); + final supabaseClient = SupabaseClient( + env['SUPB_URL']!, + // ignore: lines_longer_than_80_chars + env['SUPB_SERVICE_ROLE']!, // This is only used in the server, not on the client side + ); + authRepo = AuthRepo(supabaseClient: supabaseClient); + return serve(handler, ip, port); +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 79a0657..457d5d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,8 @@ environment: dependencies: dart_frog: ^1.0.0 + dart_frog_auth: ^1.1.0 + dotenv: ^4.2.0 supabase: ^2.1.1 dev_dependencies: diff --git a/routes/_middleware.dart b/routes/_middleware.dart new file mode 100644 index 0000000..2d15526 --- /dev/null +++ b/routes/_middleware.dart @@ -0,0 +1,17 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +import '../main.dart'; + +Handler middleware(Handler handler) { + return handler + .use(requestLogger()) + + /// AuthRepo will be used as a middleware: + /// 1. Send the OTP to the user. + /// 2. Validate the OTP supplied by the user. + /// 3. Authenticate every inbound request when + /// needed (whether the access token supplied is valid). + /// 4. To refresh the access token. + .use(provider((_) => authRepo)); +} diff --git a/routes/api/auth/otp/send.dart b/routes/api/auth/otp/send.dart new file mode 100644 index 0000000..dc841cc --- /dev/null +++ b/routes/api/auth/otp/send.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:touch_cut_server/server_exception.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +Future onRequest(RequestContext context) async { + if (context.request.method == HttpMethod.post) { + // Send OTP + return _sendOtp(context); + } else { + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +Future _sendOtp(RequestContext context) async { + print('Sending or resending OTP'); + final authRepo = context.read(); + try { + final body = await context.request.json() as Map; + final params = context.request.uri.queryParameters; + + final resend = params['resend']; + + final phoneNumber = body['phone_number'] as String?; + final userMetadata = body['user_metadata'] as Map?; + + // Phone number must be given + if (phoneNumber == null) { + print('Phone number is null'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'Phone number is missing'}, + ); + } + + // Resend OTP + if (resend != null && resend.toLowerCase() == 'true') { + print('Resending OTP'); + await authRepo.resendOtpToUser( + phoneNumber, + ); + return Response.json(); + } + + final phoneNumberExists = + await authRepo.phoneNumberExists(phoneNumber: phoneNumber); + if (userMetadata == null) { + // Signing in old user + print('Signing in old user, $phoneNumber'); + if (phoneNumberExists) { + // Phone number exists in system, we can proceed + await authRepo.sendOtpToUser( + phoneNumber: phoneNumber, + ); + return Response.json(); + } else { + // Phone number does not exist in system, we can't proceed with the + // log in + return Response.json( + statusCode: HttpStatus.notFound, + body: { + 'error_message': + 'User with the provided phone number not found, please sign up', + }, + ); + } + } else { + print('Signing in new user, $phoneNumber, with metadata $userMetadata'); + // userMetadata will be in the form + // {'first_name': , 'last_name': , 'village': } + if (userMetadata + case { + 'first_name': String _, + 'last_name': final String _, + 'village': final String _, + }) { + await authRepo.sendOtpToUser( + phoneNumber: phoneNumber, + data: userMetadata, + ); + return Response.json(); + } else { + print('User metadata has wrong format, $userMetadata'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'User metadata has wrong format'}, + ); + } + } + } on ServerException catch (serverException) { + print('Got ServerException. $serverException'); + return Response.json( + statusCode: serverException.errorCode, + body: {'error_message': serverException.errorMessage}, + ); + } catch (err) { + print('Got general exception. $err'); + return Response.json( + statusCode: HttpStatus.internalServerError, + body: {'error_message': err.toString()}, + ); + } +} diff --git a/routes/api/auth/otp/verify.dart b/routes/api/auth/otp/verify.dart new file mode 100644 index 0000000..470cf65 --- /dev/null +++ b/routes/api/auth/otp/verify.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:touch_cut_server/server_exception.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +Future onRequest(RequestContext context) async { + if (context.request.method == HttpMethod.post) { + // Create session by submitting OTP from user + return _verifyOTP(context); + } else { + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Verifies the OTP sent by the user and creates a session for him +/// We return the access and refresh token, where the client will need to +/// save them locally +Future _verifyOTP(RequestContext context) async { + print('Verifying OTP'); + final authRepo = context.read(); + try { + final body = await context.request.json() as Map; + final otpToken = body['otp_token'] as String?; + final phoneNumber = body['phone_number'] as String?; + if (otpToken != null && phoneNumber != null) { + final tokens = + await authRepo.verifyOTP(phoneNumber: phoneNumber, token: otpToken); + return Response.json(body: tokens); + } else { + print('Phone number, or OTP are missing. Bad request'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'Phone number, or OTP are missing'}, + ); + } + } on ServerException catch (serverException) { + print('Got ServerException. $serverException'); + return Response.json( + statusCode: serverException.errorCode, + body: {'error_message': serverException.errorMessage}, + ); + } catch (err) { + print('Got general exception. $err'); + return Response.json( + statusCode: HttpStatus.internalServerError, + body: {'error_message': err.toString()}, + ); + } +} diff --git a/routes/api/auth/refresh.dart b/routes/api/auth/refresh.dart new file mode 100644 index 0000000..56c55c1 --- /dev/null +++ b/routes/api/auth/refresh.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:touch_cut_server/server_exception.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +Future onRequest(RequestContext context) async { + if (context.request.method == HttpMethod.post) { + // Refresh session + return _refreshAccessToken(context); + } else { + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Refresh session using refresh token +Future _refreshAccessToken(RequestContext context) async { + print('Refreshing access token'); + final authRepo = context.read(); + try { + final body = await context.request.json() as Map; + final refreshToken = body['refresh_token'] as String?; + if (refreshToken != null) { + final tokens = + await authRepo.refreshAccessToken(refreshToken: refreshToken); + return Response.json(body: tokens); + } else { + print('Refresh token is missing. Bad request'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'Refresh token is missing'}, + ); + } + } on ServerException catch (serverException) { + print('Got ServerException. $serverException'); + return Response.json( + statusCode: serverException.errorCode, + body: {'error_message': serverException.errorMessage}, + ); + } catch (err) { + print('Got general exception. $err'); + return Response.json( + statusCode: HttpStatus.internalServerError, + body: {'error_message': err.toString()}, + ); + } +} diff --git a/routes/api/v1/_middleware.dart b/routes/api/v1/_middleware.dart new file mode 100644 index 0000000..74f869f --- /dev/null +++ b/routes/api/v1/_middleware.dart @@ -0,0 +1,17 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:dart_frog_auth/dart_frog_auth.dart'; +import 'package:supabase/supabase.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +Handler middleware(Handler handler) { + return handler.use(requestLogger()).use( + bearerAuthentication( + authenticator: (context, accessToken) async { + final authenticator = context.read(); + return authenticator.verifyAccessToken( + accessToken: accessToken, + ); + }, + ), + ); +} diff --git a/routes/api/v1/otp/send.dart b/routes/api/v1/otp/send.dart deleted file mode 100644 index 0e823cf..0000000 --- a/routes/api/v1/otp/send.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:dart_frog/dart_frog.dart'; - -Response onRequest(RequestContext context) { - // TODO: implement route handler - return Response(body: 'This is a new route!'); -} diff --git a/routes/api/v1/signout.dart b/routes/api/v1/signout.dart new file mode 100644 index 0000000..c796e43 --- /dev/null +++ b/routes/api/v1/signout.dart @@ -0,0 +1,6 @@ +import 'dart:io'; +import 'package:dart_frog/dart_frog.dart'; + +Future onRequest(RequestContext context) async { + return Response(statusCode: HttpStatus.methodNotAllowed); +} From 34032960ee9bee99ecc15ae98fcda6b2544378f7 Mon Sep 17 00:00:00 2001 From: Anas Yousef <44998563+anas-yousef@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:59:28 +0300 Subject: [PATCH 4/6] Added error handling when signing in new user, and phone number found --- routes/api/auth/otp/send.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/routes/api/auth/otp/send.dart b/routes/api/auth/otp/send.dart index dc841cc..771787f 100644 --- a/routes/api/auth/otp/send.dart +++ b/routes/api/auth/otp/send.dart @@ -66,7 +66,16 @@ Future _sendOtp(RequestContext context) async { ); } } else { - print('Signing in new user, $phoneNumber, with metadata $userMetadata'); + print('Signing up new user, $phoneNumber, with metadata $userMetadata'); + if (phoneNumberExists) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error_message': + 'User with the provided phone number already exists, please sign in', + }, + ); + } // userMetadata will be in the form // {'first_name': , 'last_name': , 'village': } if (userMetadata From a7a6b4e78fdab4c62fbc1125c78220b8f3ad6a2d Mon Sep 17 00:00:00 2001 From: Anas Yousef <44998563+anas-yousef@users.noreply.github.com> Date: Mon, 29 Apr 2024 21:31:12 +0300 Subject: [PATCH 5/6] minor fixes --- lib/src/auth/auth_repo.dart | 1 + routes/api/auth/otp/send.dart | 4 ++-- routes/api/auth/refresh.dart | 6 +++--- routes/api/v1/validateToken.dart | 20 ++++++++++++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 routes/api/v1/validateToken.dart diff --git a/lib/src/auth/auth_repo.dart b/lib/src/auth/auth_repo.dart index e1a8927..02e84d9 100644 --- a/lib/src/auth/auth_repo.dart +++ b/lib/src/auth/auth_repo.dart @@ -25,6 +25,7 @@ class AuthRepo { required String phoneNumber, Map? data, }) async { + print(data); try { await supabaseClient.auth.signInWithOtp( phone: phoneNumber, diff --git a/routes/api/auth/otp/send.dart b/routes/api/auth/otp/send.dart index 771787f..056cfbd 100644 --- a/routes/api/auth/otp/send.dart +++ b/routes/api/auth/otp/send.dart @@ -43,8 +43,8 @@ Future _sendOtp(RequestContext context) async { return Response.json(); } - final phoneNumberExists = - await authRepo.phoneNumberExists(phoneNumber: phoneNumber); + final phoneNumberExists = false; + // await authRepo.phoneNumberExists(phoneNumber: phoneNumber); if (userMetadata == null) { // Signing in old user print('Signing in old user, $phoneNumber'); diff --git a/routes/api/auth/refresh.dart b/routes/api/auth/refresh.dart index 56c55c1..aa02844 100644 --- a/routes/api/auth/refresh.dart +++ b/routes/api/auth/refresh.dart @@ -20,15 +20,15 @@ Future _refreshAccessToken(RequestContext context) async { try { final body = await context.request.json() as Map; final refreshToken = body['refresh_token'] as String?; - if (refreshToken != null) { + if (refreshToken != null && refreshToken.isNotEmpty) { final tokens = await authRepo.refreshAccessToken(refreshToken: refreshToken); return Response.json(body: tokens); } else { - print('Refresh token is missing. Bad request'); + print('Refresh token is missing or empty. Bad request'); return Response.json( statusCode: HttpStatus.badRequest, - body: {'error_message': 'Refresh token is missing'}, + body: {'error_message': 'Refresh token is missing or empty'}, ); } } on ServerException catch (serverException) { diff --git a/routes/api/v1/validateToken.dart b/routes/api/v1/validateToken.dart new file mode 100644 index 0000000..438d4cd --- /dev/null +++ b/routes/api/v1/validateToken.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; + +Response onRequest(RequestContext context) { + if (context.request.method == HttpMethod.get) { + // A dummy endpoint to validate access token + // This route goes through a middleware that verifies the access token, + // so if the access token is valid and we reach this route, that means + // we return 200, if the access token is not valid, then the middleware + // will return a 401, and we won't reach this route + return _validateToken(context); + } else { + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +Response _validateToken(RequestContext context) { + return Response.json(); +} From d8376430c2848e4598ced17796182564be246142 Mon Sep 17 00:00:00 2001 From: Anas Yousef <44998563+anas-yousef@users.noreply.github.com> Date: Thu, 23 May 2024 07:02:16 +0300 Subject: [PATCH 6/6] Added email and phone OTP verification flow --- .gitignore | 3 +- lib/src/auth/auth_repo.dart | 80 +++++++++--- lib/src/file_storage.dart | 44 +++++++ main.dart | 7 +- routes/api/auth/otp/email/send.dart | 115 ++++++++++++++++++ routes/api/auth/otp/email/verify.dart | 52 ++++++++ .../api/auth/otp/{ => phoneNumber}/send.dart | 18 +-- .../auth/otp/{ => phoneNumber}/verify.dart | 2 +- 8 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 lib/src/file_storage.dart create mode 100644 routes/api/auth/otp/email/send.dart create mode 100644 routes/api/auth/otp/email/verify.dart rename routes/api/auth/otp/{ => phoneNumber}/send.dart (87%) rename routes/api/auth/otp/{ => phoneNumber}/verify.dart (97%) diff --git a/.gitignore b/.gitignore index 628be94..ee34535 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ build/ coverage/ # Environment files -.env \ No newline at end of file +.env +supabase_auth.json \ No newline at end of file diff --git a/lib/src/auth/auth_repo.dart b/lib/src/auth/auth_repo.dart index 02e84d9..3e39a2e 100644 --- a/lib/src/auth/auth_repo.dart +++ b/lib/src/auth/auth_repo.dart @@ -22,12 +22,17 @@ class AuthRepo { /// Send OTP to user Future sendOtpToUser({ - required String phoneNumber, + String? phoneNumber, + String? email, Map? data, }) async { - print(data); try { + _validateEmailAndPhoneNumber( + email: email, + phoneNumber: phoneNumber, + ); await supabaseClient.auth.signInWithOtp( + email: email, phone: phoneNumber, data: data, ); @@ -38,6 +43,7 @@ class AuthRepo { errorMessage: authException.toString(), ); } catch (err) { + print(err); throw AuthRepoException( errorMessage: err.toString(), ); @@ -45,15 +51,20 @@ class AuthRepo { } /// Resend OTP to user - Future resendOtpToUser( - String phoneNumber, - ) async { + Future resendOtpToUser({ + String? phoneNumber, + String? email, + }) async { try { - final resendResponse = await supabaseClient.auth.resend( - type: OtpType.sms, + _validateEmailAndPhoneNumber( + email: email, + phoneNumber: phoneNumber, + ); + await supabaseClient.auth.resend( + type: phoneNumber != null ? OtpType.sms : OtpType.email, phone: phoneNumber, + email: email, ); - // print(resendResponse.messageId); } on AuthException catch (authException) { print(authException); throw AuthRepoException( @@ -61,6 +72,7 @@ class AuthRepo { errorMessage: authException.toString(), ); } catch (err) { + print(err); throw AuthRepoException( errorMessage: err.toString(), ); @@ -90,15 +102,22 @@ class AuthRepo { /// the access and refresh token, after creating a session /// for the user Future> verifyOTP({ - required String phoneNumber, required String token, + String? phoneNumber, + String? email, }) async { try { + _validateEmailAndPhoneNumber( + email: email, + phoneNumber: phoneNumber, + ); final authResponse = await supabaseClient.auth.verifyOTP( - type: OtpType.sms, + type: phoneNumber != null ? OtpType.sms : OtpType.email, token: token, phone: phoneNumber, + email: email, ); + print('Hello $authResponse'); final session = authResponse.session!; return { 'access_token': session.accessToken, @@ -117,6 +136,18 @@ class AuthRepo { } } + void _validateEmailAndPhoneNumber({ + String? email, + String? phoneNumber, + }) { + if (phoneNumber == null && email == null) { + throw Exception('Phone number and email cannot be null'); + } + if (phoneNumber != null && email != null) { + throw Exception('Only supply either email or phone number'); + } + } + /// Refresh access and refresh token Future> refreshAccessToken({ required String refreshToken, @@ -140,19 +171,21 @@ class AuthRepo { } } - /// This function checks if the supplied phone number from the user is - /// in the DB, while trying to sign in. If not found, that means no user + /// This function checks if the supplied phone number from the customer is + /// in the DB, while trying to sign in. If not found, that means no customer /// exits with that phone number, therefore, we will return an error asking - /// the user to sign-up, if found, then we will go ahead and send the user - /// an SMS containing an OTP. - Future phoneNumberExists({required String phoneNumber}) async { + /// them to sign-up, if found, we will go ahead and send an SMS containing + /// an OTP. + /// IMPORTANT -> This is used to check customers, since customers sign in + /// using phone numbers. + Future customerPhoneNumberExists({required String phoneNumber}) async { // Supabase DB saves the phone number without the + sign var phoneNumberToCheck = phoneNumber; if (phoneNumber.startsWith('+')) { phoneNumberToCheck = phoneNumber.substring(1); } final data = await supabaseClient - .from('users') + .from('customers') .select() .eq('phone_number', phoneNumberToCheck); if (data.isEmpty) { @@ -162,4 +195,19 @@ class AuthRepo { print('$phoneNumber found in the system'); return true; } + + /// Same check for the customer phone number, but in here, we check + /// the email of the barbershop + /// IMPORTANT -> This is used to check barbershops, since they sign in + /// using emails. + Future barbershopEmailExists({required String email}) async { + final data = + await supabaseClient.from('barbershops').select().eq('email', email); + if (data.isEmpty) { + print('$email not found in the system'); + return false; + } + print('$email found in the system'); + return true; + } } diff --git a/lib/src/file_storage.dart b/lib/src/file_storage.dart new file mode 100644 index 0000000..33fcb1a --- /dev/null +++ b/lib/src/file_storage.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase/supabase.dart'; + +class FileStorage implements GotrueAsyncStorage { + FileStorage(String path) : file = File(path); + final File file; + + @override + Future removeItem({required String key}) async { + final json = await _readJson(); + json.remove(key); + await _writeJson(json); + } + + @override + Future getItem({required String key}) async { + final json = await _readJson(); + return json[key] as String?; + } + + @override + Future setItem({ + required String key, + required String value, + }) async { + final json = await _readJson(); + json[key] = value; + await _writeJson(json); + } + + Future> _readJson() async { + if (!await file.exists()) { + return {}; + } + final contents = await file.readAsString(); + return jsonDecode(contents) as Map; + } + + Future _writeJson(Map json) async { + await file.writeAsString(jsonEncode(json)); + } +} diff --git a/main.dart b/main.dart index 9df581a..da93664 100644 --- a/main.dart +++ b/main.dart @@ -4,16 +4,21 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:dotenv/dotenv.dart'; import 'package:supabase/supabase.dart'; import 'package:touch_cut_server/src/auth/auth_repo.dart'; +import 'package:touch_cut_server/src/file_storage.dart'; late AuthRepo authRepo; Future run(Handler handler, InternetAddress ip, int port) { + final storage = FileStorage('supabase_auth.json'); final env = DotEnv(includePlatformEnvironment: true)..load(); final supabaseClient = SupabaseClient( env['SUPB_URL']!, // ignore: lines_longer_than_80_chars env['SUPB_SERVICE_ROLE']!, // This is only used in the server, not on the client side + authOptions: AuthClientOptions( + pkceAsyncStorage: storage, + ), ); authRepo = AuthRepo(supabaseClient: supabaseClient); return serve(handler, ip, port); -} \ No newline at end of file +} diff --git a/routes/api/auth/otp/email/send.dart b/routes/api/auth/otp/email/send.dart new file mode 100644 index 0000000..11b6011 --- /dev/null +++ b/routes/api/auth/otp/email/send.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:touch_cut_server/server_exception.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +Future onRequest(RequestContext context) async { + if (context.request.method == HttpMethod.post) { + // Send OTP + return _sendOtp(context); + } else { + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +Future _sendOtp(RequestContext context) async { + print('Sending or resending email OTP'); + final authRepo = context.read(); + try { + final body = await context.request.json() as Map; + final params = context.request.uri.queryParameters; + + final resend = params['resend']; + + final email = body['email'] as String?; + final userMetadata = body['user_metadata'] as Map?; + + // Email must be given + if (email == null) { + print('Email is null'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'Email is missing'}, + ); + } + + // Resend OTP + if (resend != null && resend.toLowerCase() == 'true') { + print('Resending OTP'); + await authRepo.resendOtpToUser( + email: email, + ); + return Response.json(); + } + + final barbershopEmailExists = + await authRepo.barbershopEmailExists(email: email); + if (userMetadata == null) { + // Signing in old user + print('Signing in old user, $email'); + if (barbershopEmailExists) { + // Phone number exists in system, we can proceed + await authRepo.sendOtpToUser( + email: email, + ); + return Response.json(); + } else { + // Phone number does not exist in system, we can't proceed with the + // log in + return Response.json( + statusCode: HttpStatus.notFound, + body: { + 'error_message': + 'User with the provided email not found, please sign up', + }, + ); + } + } else { + print('Signing up new user, $email, with metadata $userMetadata'); + if (barbershopEmailExists) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error_message': + 'User with the provided email already exists, please sign in', + }, + ); + } + // userMetadata will be in the form + // {'first_name': , 'last_name': , 'village': , + // 'phone_number': } + if (userMetadata + case { + 'first_name': String _, + 'last_name': final String _, + 'village': final String _, + 'phone_number': final String _, + }) { + await authRepo.sendOtpToUser( + email: email, + data: userMetadata, + ); + return Response.json(); + } else { + print('User metadata has wrong format, $userMetadata'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'User metadata has wrong format'}, + ); + } + } + } on ServerException catch (serverException) { + print('Got ServerException. $serverException'); + return Response.json( + statusCode: serverException.errorCode, + body: {'error_message': serverException.errorMessage}, + ); + } catch (err) { + print('Got general exception. $err'); + return Response.json( + statusCode: HttpStatus.internalServerError, + body: {'error_message': err.toString()}, + ); + } +} diff --git a/routes/api/auth/otp/email/verify.dart b/routes/api/auth/otp/email/verify.dart new file mode 100644 index 0000000..1da7ed5 --- /dev/null +++ b/routes/api/auth/otp/email/verify.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:touch_cut_server/server_exception.dart'; +import 'package:touch_cut_server/src/auth/auth_repo.dart'; + +Future onRequest(RequestContext context) async { + if (context.request.method == HttpMethod.post) { + // Create session by submitting OTP from user + return _verifyOTP(context); + } else { + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Verifies the OTP sent by the user and creates a session for him +/// We return the access and refresh token, where the client will need to +/// save them locally +Future _verifyOTP(RequestContext context) async { + print('Verifying email OTP'); + final authRepo = context.read(); + try { + final body = await context.request.json() as Map; + final otpToken = body['otp_token'] as String?; + final email = body['email'] as String?; + if (otpToken != null && email != null) { + final tokens = await authRepo.verifyOTP( + email: email, + token: otpToken, + ); + return Response.json(body: tokens); + } else { + print('Email, or OTP are missing. Bad request'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'Email, or OTP are missing'}, + ); + } + } on ServerException catch (serverException) { + print('Got ServerException. $serverException'); + return Response.json( + statusCode: serverException.errorCode, + body: {'error_message': serverException.errorMessage}, + ); + } catch (err) { + print('Got general exception. $err'); + return Response.json( + statusCode: HttpStatus.internalServerError, + body: {'error_message': err.toString()}, + ); + } +} diff --git a/routes/api/auth/otp/send.dart b/routes/api/auth/otp/phoneNumber/send.dart similarity index 87% rename from routes/api/auth/otp/send.dart rename to routes/api/auth/otp/phoneNumber/send.dart index 056cfbd..1ac481e 100644 --- a/routes/api/auth/otp/send.dart +++ b/routes/api/auth/otp/phoneNumber/send.dart @@ -14,7 +14,7 @@ Future onRequest(RequestContext context) async { } Future _sendOtp(RequestContext context) async { - print('Sending or resending OTP'); + print('Sending or resending phone OTP'); final authRepo = context.read(); try { final body = await context.request.json() as Map; @@ -25,8 +25,10 @@ Future _sendOtp(RequestContext context) async { final phoneNumber = body['phone_number'] as String?; final userMetadata = body['user_metadata'] as Map?; + print('Phone number is $phoneNumber'); + print('User metadata is $userMetadata'); // Phone number must be given - if (phoneNumber == null) { + if (phoneNumber == null || phoneNumber.isEmpty) { print('Phone number is null'); return Response.json( statusCode: HttpStatus.badRequest, @@ -38,17 +40,17 @@ Future _sendOtp(RequestContext context) async { if (resend != null && resend.toLowerCase() == 'true') { print('Resending OTP'); await authRepo.resendOtpToUser( - phoneNumber, + phoneNumber: phoneNumber, ); return Response.json(); } - final phoneNumberExists = false; - // await authRepo.phoneNumberExists(phoneNumber: phoneNumber); + final customerPhoneNumberExists = + await authRepo.customerPhoneNumberExists(phoneNumber: phoneNumber); if (userMetadata == null) { // Signing in old user - print('Signing in old user, $phoneNumber'); - if (phoneNumberExists) { + print('Signing in old user: $phoneNumber'); + if (customerPhoneNumberExists) { // Phone number exists in system, we can proceed await authRepo.sendOtpToUser( phoneNumber: phoneNumber, @@ -67,7 +69,7 @@ Future _sendOtp(RequestContext context) async { } } else { print('Signing up new user, $phoneNumber, with metadata $userMetadata'); - if (phoneNumberExists) { + if (customerPhoneNumberExists) { return Response.json( statusCode: HttpStatus.badRequest, body: { diff --git a/routes/api/auth/otp/verify.dart b/routes/api/auth/otp/phoneNumber/verify.dart similarity index 97% rename from routes/api/auth/otp/verify.dart rename to routes/api/auth/otp/phoneNumber/verify.dart index 470cf65..dfb01d1 100644 --- a/routes/api/auth/otp/verify.dart +++ b/routes/api/auth/otp/phoneNumber/verify.dart @@ -17,7 +17,7 @@ Future onRequest(RequestContext context) async { /// We return the access and refresh token, where the client will need to /// save them locally Future _verifyOTP(RequestContext context) async { - print('Verifying OTP'); + print('Verifying phone number OTP'); final authRepo = context.read(); try { final body = await context.request.json() as Map;