diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 67dfe64..ee34535 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,8 @@ build/ .dart_frog # Test related files -coverage/ \ No newline at end of file +coverage/ + +# Environment files +.env +supabase_auth.json \ No newline at end of file diff --git a/lib/server_exception.dart b/lib/server_exception.dart new file mode 100644 index 0000000..56a82e7 --- /dev/null +++ b/lib/server_exception.dart @@ -0,0 +1,27 @@ +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 != null + ? int.parse(errorCode) + : HttpStatus.internalServerError; + + /// The error message received + final String errorMessage; + + /// Error code of exception + final int 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..3e39a2e --- /dev/null +++ b/lib/src/auth/auth_repo.dart @@ -0,0 +1,213 @@ +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, + String? email, + Map? data, + }) async { + try { + _validateEmailAndPhoneNumber( + email: email, + phoneNumber: phoneNumber, + ); + await supabaseClient.auth.signInWithOtp( + email: email, + phone: phoneNumber, + data: data, + ); + } on AuthException catch (authException) { + print(authException); + throw AuthRepoException( + errorCode: authException.statusCode, + errorMessage: authException.toString(), + ); + } catch (err) { + print(err); + throw AuthRepoException( + errorMessage: err.toString(), + ); + } + } + + /// Resend OTP to user + Future resendOtpToUser({ + String? phoneNumber, + String? email, + }) async { + try { + _validateEmailAndPhoneNumber( + email: email, + phoneNumber: phoneNumber, + ); + await supabaseClient.auth.resend( + type: phoneNumber != null ? OtpType.sms : OtpType.email, + phone: phoneNumber, + email: email, + ); + } on AuthException catch (authException) { + print(authException); + throw AuthRepoException( + errorCode: authException.statusCode, + errorMessage: authException.toString(), + ); + } catch (err) { + print(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(), + ); + } + } + + /// 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 token, + String? phoneNumber, + String? email, + }) async { + try { + _validateEmailAndPhoneNumber( + email: email, + phoneNumber: phoneNumber, + ); + final authResponse = await supabaseClient.auth.verifyOTP( + 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, + '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(), + ); + } + } + + 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, + }) 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(), + ); + } + } + + /// 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 + /// 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('customers') + .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; + } + + /// 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 new file mode 100644 index 0000000..da93664 --- /dev/null +++ b/main.dart @@ -0,0 +1,24 @@ +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'; +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); +} diff --git a/pubspec.yaml b/pubspec.yaml index 8808224..457d5d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,9 @@ environment: dependencies: dart_frog: ^1.0.0 + dart_frog_auth: ^1.1.0 + dotenv: ^4.2.0 + supabase: ^2.1.1 dev_dependencies: mocktail: ^1.0.0 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/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/phoneNumber/send.dart b/routes/api/auth/otp/phoneNumber/send.dart new file mode 100644 index 0000000..1ac481e --- /dev/null +++ b/routes/api/auth/otp/phoneNumber/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 phone 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?; + + print('Phone number is $phoneNumber'); + print('User metadata is $userMetadata'); + // Phone number must be given + if (phoneNumber == null || phoneNumber.isEmpty) { + 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: phoneNumber, + ); + return Response.json(); + } + + final customerPhoneNumberExists = + await authRepo.customerPhoneNumberExists(phoneNumber: phoneNumber); + if (userMetadata == null) { + // Signing in old user + print('Signing in old user: $phoneNumber'); + if (customerPhoneNumberExists) { + // 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 up new user, $phoneNumber, with metadata $userMetadata'); + if (customerPhoneNumberExists) { + 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 + 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/phoneNumber/verify.dart b/routes/api/auth/otp/phoneNumber/verify.dart new file mode 100644 index 0000000..dfb01d1 --- /dev/null +++ b/routes/api/auth/otp/phoneNumber/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 phone number 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..aa02844 --- /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 && refreshToken.isNotEmpty) { + final tokens = + await authRepo.refreshAccessToken(refreshToken: refreshToken); + return Response.json(body: tokens); + } else { + print('Refresh token is missing or empty. Bad request'); + return Response.json( + statusCode: HttpStatus.badRequest, + body: {'error_message': 'Refresh token is missing or empty'}, + ); + } + } 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/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); +} 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(); +}