๐Ÿš€ Building a Fullstack App with dart_frog and Flutter in a Monorepo - Part 6

๐Ÿš€ Building a Fullstack App with dart_frog and Flutter in a Monorepo - Part 6

Implementing user authentication with JWT and password hashing in a full-stack CRUD app built with dart_frog and flutter in a Monorepo

ยท

23 min read

In earlier sections of this tutorial series, we covered the fundamental stages for creating a to-do application with Flutter and Dart.

We've covered everything from bootstrapping an empty Flutter project, importing necessary dependencies, setting up the folder structure, integrating the frontend with the backend, and implementing the Todo data sources, repositories, and view models.

In this part, we'll show you how to utilize dart frog to establish user authentication with JSON Web Tokens (JWT). By the end of this article, we will be able to:

  • Implement user login and register.

  • Create User model.

  • Create and implement UserRepository and UserDataSource.

  • Handle UnauthorizedException.

  • Create a new authorization middleware.

  • Implement user authentication using JWT.

  • Secure routes with Authorization headers.

Don't forget to check out the GitHub repo for this article if you need any assistance along the way.

Let's get started ๐Ÿš€


Overview ๐Ÿ”

๐Ÿ‘€ Get a sneak peek of what's to come ๐Ÿ”ฎ

When we're done, we should have an app that supports the following requests:

  1. /todos* - Guard all to-do routes with Authorization header.
curl -s -L -X POST 'http://localhost:8080/todos' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY5ZWFkZTg3LWRhNWUtNDRjZC1iNTRlLTQ3NGE4NWQ4ZGVlNCIsIm5hbWUiOiJTYWlsZXNoIERhaGFsIiwiZW1haWwiOiJzYWlsZXNoYnJvQGdtYWlsLmNvbSIsImNyZWF0ZWRfYXQiOiIyMDIzLTAxLTIzVDIyOjU0OjM4Ljg2NDkxMloiLCJwYXNzd29yZCI6IiQyYSQxMCR6VjBTZlI3cUpHOHlCelJWWThtNkRlMWo0UUtHL2VRdXl6NzduQ1lBL1luZENtb1ZSbzB0RyIsImlhdCI6MTY3NDQ5Mzc3OX0.W36mHXKFrxZDhz0Hrxt0Cmrz3WNVexiAZe2KAjrWMaE' -H 'Content-Type: application/json' --data-raw '{
    "title":"this is a title asdf",
    "description":"Not so great description asdf"
}'
  1. /users/login - Login user
curl -s -L -X POST 'http://localhost:8080/users/login' -H 'Content-Type: application/json' --data-raw '{
    "email":"saileshbro@gmail.com",
    "password":"6aMj@UBByu"
}'
  1. /users/signup - Register new user
curl -s -L -X POST 'http://localhost:8080/users/signup' -H 'Content-Type: application/json' --data-raw '{
    "email":"saileshbro@gmail.com",
    "name":"Sailesh Dahal",
    "password":"6aMj@UBByu%7BzN^C9tMe#Te4b!4cJrXwwFi#HgKrQ&g&"
}'

Updating Database ๐Ÿ› ๏ธ

๐Ÿ’พ Upgrading our database to its full potential ๐Ÿš€

To begin, we will create a new table called users and add a foreign key to our current todos table. This ensures that each to-do item is associated with a single user, and that data is accessible only to that person.

Create users table

We will create a new users table with the following columns.

  • id : unique uuid

  • name : name of the user

  • email : email of the user

  • password : hashed password of the user

  • created_at : timestamp of when the user was created

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users(
    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email TEXT NOT NULL,
      password TEXT NOT NULL,
    created_at timestamp default current_timestamp NOT NULL
);

The uuid-ossp extension is used to generate unique IDs for each user.

Updating todos table

We will update the todos table to include a foreign key to the users table.

TRUNCATE TABLE todos;
ALTER TABLE todos
    ADD COLUMN user_id uuid NOT NULL,
    ADD CONSTRAINT constraint_fk
        FOREIGN KEY (user_id)
            REFERENCES users (id)
            ON DELETE CASCADE;

The ON DELETE CASCADE clause ensures that when a user is deleted, all their to-do items will be deleted.

Once we are done with the changes, we can proceed to create a user schema in our models package.

More about CONSTRAINTS here.


Create User model

๐Ÿง‘โ€๐Ÿ’ผ Defining our User Model ๐Ÿ“

We will start by creating a UserId in our typedefs package.

Create UserId type

In typedefs package, we will make some changes. We will rename the older src/typedefs.dart file to src/todo_types.dart and create a new src/user_types.dart file.

typedef UserId = String;

๐Ÿ’ก Make sure to update the exports.

library typedefs;

export 'src/todo_types.dart';
export 'src/user_types.dart';

Update folder structure

In models package, we will create a new user.dart file. Since we are creating models for a different feature, we will mode the todo specific models to a new todo folder.

.
โ”œโ”€โ”€ create_todo_dto
โ”‚   โ””โ”€โ”€ create_todo_dto.dart
โ”œโ”€โ”€ todo.dart
โ””โ”€โ”€ update_todo_dto
    โ””โ”€โ”€ update_todo_dto.dart

Also, make sure to export create_todo_dto and update_todo_dto from todo.dart

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:models/src/serializers/date_time_converter.dart';
import 'package:typedefs/typedefs.dart';

export './create_todo_dto/create_todo_dto.dart';
export './update_todo_dto/update_todo_dto.dart';

part 'todo.freezed.dart';
part 'todo.g.dart';

Create User model

Now, we will create a User model in src/user/user.dart file.

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:models/src/serializers/date_time_converter.dart';
import 'package:typedefs/typedefs.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required UserId id,
    required String name,
    required String email,
    @DateTimeConverter() required DateTime createdAt,
    @Default('') @JsonKey(includeToJson: false) String password,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

We are ignoring the password field when serializing the model to JSON. This is because we don't want to send the password to the client.

Similarly, we will create CreateUserDto and LoginUserDto for creating and logging in users.

In src/user/create_user_dto/create_user_dto.dart file,

import 'package:either_dart/either.dart';
import 'package:exceptions/exceptions.dart';
import 'package:failures/failures.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'create_user_dto.freezed.dart';
part 'create_user_dto.g.dart';

@freezed
class CreateUserDto with _$CreateUserDto {
  factory CreateUserDto({
    required String name,
    required String email,
    required String password,
  }) = _CreateUserDto;

  factory CreateUserDto.fromJson(Map<String, dynamic> json) =>
      _$CreateUserDtoFromJson(json);

  static Either<ValidationFailure, CreateUserDto> validated(
    Map<String, dynamic> json,
  ) {
    try {
      final errors = <String, List<String>>{};
      final name = json['name'] as String? ?? '';
      final email = json['email'] as String? ?? '';
      final password = json['password'] as String? ?? '';
      if (name.isEmpty) {
        errors['name'] = ['Name is required'];
      }
      if (email.isEmpty) {
        errors['email'] = ['Email is required'];
      }
      if (!email.contains('@')) {
        errors['email'] = ['Email is invalid'];
      }
      if (password.isEmpty) {
        errors['password'] = ['Password is required'];
      }
      if (password.length < 6) {
        errors['password'] = ['Password must be at least 6 characters'];
      }

      if (errors.isEmpty) return Right(CreateUserDto.fromJson(json));
      throw BadRequestException(
        message: 'Validation failed',
        errors: errors,
      );
    } on BadRequestException catch (e) {
      return Left(
        ValidationFailure(
          message: e.message,
          errors: e.errors,
          statusCode: e.statusCode,
        ),
      );
    }
  }
}

Similarly, we will create a new login_user_dto.

import 'package:either_dart/either.dart';
import 'package:exceptions/exceptions.dart';
import 'package:failures/failures.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'login_user_dto.freezed.dart';
part 'login_user_dto.g.dart';

@freezed
class LoginUserDto with _$LoginUserDto {
  factory LoginUserDto({
    required String email,
    required String password,
  }) = _LoginUserDto;

  factory LoginUserDto.fromJson(Map<String, dynamic> json) =>
      _$LoginUserDtoFromJson(json);

  static Either<ValidationFailure, LoginUserDto> validated(
    Map<String, dynamic> json,
  ) {
    try {
      final errors = <String, List<String>>{};
      final email = json['email'] as String? ?? '';
      final password = json['password'] as String? ?? '';
      if (email.isEmpty) {
        errors['email'] = ['Email is required'];
      }
      if (password.isEmpty) {
        errors['password'] = ['Password is required'];
      }
      if (errors.isEmpty) return Right(LoginUserDto.fromJson(json));
      throw BadRequestException(
        message: 'Validation failed',
        errors: errors,
      );
    } on BadRequestException catch (e) {
      return Left(
        ValidationFailure(
          message: e.message,
          errors: e.errors,
          statusCode: e.statusCode,
        ),
      );
    }
  }
}

These data transfer objects will be used to send, receive and validate data from the client in the backend.

๐Ÿ’ก Make sure to export create_user_dto and login_user_dto from user.dart, and user.dart from models.dart

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:models/src/serializers/date_time_converter.dart';
import 'package:typedefs/typedefs.dart';

export './create_user_dto/create_user_dto.dart';
export './login_user_dto/login_user_dto.dart';

part 'user.freezed.dart';
part 'user.g.dart';
library models;

export 'src/todo/todo.dart';
export 'src/user/user.dart';

๐Ÿ’ก Make sure to run flutter pub run build_runner build to generate new files.


Creating UserDataSource

๐Ÿ“Š Building the foundation for user management with UserDataSource ๐Ÿ› ๏ธ

In data_source package, we will create a new interface called UserDataSource in data_source/lib/src/user_data_source.dart

import 'package:models/models.dart';
import 'package:typedefs/typedefs.dart';

abstract class UserDataSource {
  Future<User> getUserById(UserId id);

  Future<User> createUser(CreateUserDto user);

  Future<User> getUserByEmail(String email);
}

We will have methods to query and create users. We will do the implementation in our backend.


Creating UserRepository

๐Ÿ” Designing our User Management System with UserRepository ๐Ÿ› ๏ธ

We will also create an interface called UserRepository which will return either a failure or valid data making use of UserDataSource.

In repository package, we will create a new interface called UserRepository in repository/lib/src/user_repository.dart

import 'package:either_dart/either.dart';
import 'package:failures/failures.dart';
import 'package:models/models.dart';
import 'package:typedefs/typedefs.dart';

abstract class UserRepository {
  Future<Either<Failure, User>> getUserById(UserId id);

  Future<Either<Failure, User>> createUser(CreateUserDto createUserDto);

  Future<Either<Failure, User>> loginUser(LoginUserDto loginUserDto);

  Future<Either<Failure, User>> getUserByEmail(String email);
}

We will have all the methods we need to create, query and login users. We will do the implementation in our backend.

๐Ÿ’ก Make sure to export UserRepository from repository.dart

library repository;

export 'src/todo_repository.dart';
export 'src/user_repository.dart';

Implementing UserDataSource

๐Ÿ’ป Bringing UserDataSource to life ๐Ÿ”ง

Now, we will implement UserDataSource in our backend. In backend/lib/user/data_source/user_data_source_impl.dart, we will create a new class called UserDataSourceImpl which will implement UserDataSource.

This is how an empty implementation of UserDataSourceImpl will look like.

import 'package:data_source/data_source.dart';
import 'package:models/models.dart';
import 'package:typedefs/typedefs.dart';

class UserDataSourceImpl implements UserDataSource {
  UserDataSourceImpl(this._databaseConnection);

  final DatabaseConnection _databaseConnection;
  Future<User> createUser(CreateUserDto user) {
    throw UnimplementedError();
  }

  @override
  Future<User> getUserByEmail(String email) {
    throw UnimplementedError();
  }

  @override
  Future<User> getUserById(UserId id) {
    throw UnimplementedError();
  }
}

Implementing createUser method

Now, we will implement createUser method. We will use DatabaseConnection to connect to our database and create a new user from the dto we received.

  @override
  Future<User> createUser(CreateUserDto user) async {
    try {
      await _databaseConnection.connect();
      final result = await _databaseConnection.db.query(
        '''
        INSERT INTO users (name, email, password)
        VALUES (@name, @email, @password) RETURNING *
        ''',
        substitutionValues: user.toJson(),
      );
      if (result.affectedRowCount == 0) {
        throw const ServerException('Failed to create todo');
      }
      final userMap = result.first.toColumnMap();
      return User.fromJson(userMap);
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

We will validate the DTO in the repository before passing it to the data source. The database will then be queried to generate a new user. We will throw a ServerException if the user is not created, else, we will return the user that was created.

Implementing getUserByEmail method

  @override
  Future<User> getUserByEmail(String email) async {
    try {
      await _databaseConnection.connect();
      final result = await _databaseConnection.db.query(
        '''
        SELECT id, name, email, password, created_at
        FROM users WHERE email = @email
        ''',
        substitutionValues: {'email': email},
      );
      if (result.isEmpty) {
        throw const NotFoundException('User not found');
      }
      return User.fromJson(result.first.toColumnMap());
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

Here, we will query the database for the user with the given email. If the user is not found, we will throw a NotFoundException. We will handle this exception in the UserController and return a 404 response.

Implementing getUserById method

@override
  Future<User> getUserById(UserId id) async {
    try {
      await _databaseConnection.connect();
      final result = await _databaseConnection.db.query(
        'SELECT id, name, email, created_at FROM users WHERE id = @id',
        substitutionValues: {'id': id},
      );
      if (result.isEmpty) {
        throw const NotFoundException('User not found');
      }
      return User.fromJson(result.first.toColumnMap());
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

We will query the database for the user with the given id. If the user is not found, we will throw a NotFoundException. We will handle this exception in the UserController and return a 404 response.


Creating PasswordHasherService

๐Ÿ”‘ Keeping user passwords safe with PasswordHasherService ๐Ÿ”’

We will create a new service to hash and validate hashed passwords before going on to the UserRepository implementation. The CreateUserDto with the hashed password will be passed to the UserRepository.

In backend/lib/services/password_hasher_service.dart, we will create a new class called PasswordHasherService.

We will use bcrypt package to hash and verify the password.

flutter pub add bcrypt
import 'package:bcrypt/bcrypt.dart';

class PasswordHasherService {
  const PasswordHasherService();

  String hashPassword(String password) {
    return BCrypt.hashpw(password, BCrypt.gensalt());
  }

  bool checkPassword(String password, String hashedPassword) {
    return BCrypt.checkpw(password, hashedPassword);
  }
}

Now, we can pass this service as a dependency to UserRepository when we implement our repository.


Implementing UserRepository

๐Ÿ’ป Bringing UserRepository to life ๐Ÿ”ง

Now, we will implement UserRepository in our backend. In backend/lib/user/repository/user_repository_impl.dart, we will create a new class called UserRepositoryImpl which will implement UserRepository.

import 'package:backend/services/password_hasher_service.dart';
import 'package:data_source/data_source.dart';
import 'package:either_dart/either.dart';
import 'package:failures/failures.dart';
import 'package:models/models.dart';
import 'package:repository/repository.dart';
import 'package:typedefs/typedefs.dart';

class UserRepositoryImpl implements UserRepository {
  UserRepositoryImpl(this.dataSource, this.passwordHasherService);

  final UserDataSource dataSource;
  final PasswordHasherService passwordHasherService;

  @override
  Future<Either<Failure, User>> createUser(CreateUserDto createUserDto) {
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, User>> getUserByEmail(String email) {
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, User>> getUserById(UserId id) {
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, User>> loginUser(LoginUserDto loginUserDto) {
    throw UnimplementedError();
  }
}

Implementing getUserByEmail method

We will start by implementing getUserByEmail. We will use the data source's dataSource.getUserByEmail method to see if we get a User object. If we get a User object, we shall wrap it in a Right object and return it. If an error occurs, we will return a Left object along with a ServerFailure object.

  @override
  Future<Either<Failure, User>> getUserByEmail(String email) async {
    try {
      final user = await dataSource.getUserByEmail(email);
      return Right(user);
    } catch (e) {
      log(e.toString());
      return const Left(
        ServerFailure(
          message: 'User with this email does not exist',
          statusCode: HttpStatus.notFound,
        ),
      );
    }
  }

Implementing getUserById method

Similarly, we will call dataSource.getUserById method and see whether we get a User object. If we get a User object, we shall wrap it in a Right object and return it. If an error occurs, we will return a Left object along with a ServerFailure object.

  @override
  Future<Either<Failure, User>> getUserById(UserId id) async {
    try {
      final res = await dataSource.getUserById(id);
      return Right(res);
    } on NotFoundException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(
          message: e.message,
          statusCode: e.statusCode,
        ),
      );
    } on ServerException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(message: e.message),
      );
    }
  }

Implementing loginUser method

Now, we will implement loginUser. We will check if the user with the given email exists. If the user exists, we will check the password with PasswordHasherService. If they do not match, we will return a failure, else we will return the logged in user.

This method will be used by the UserController to create an access token for the user.

  @override
  Future<Either<Failure, User>> loginUser(LoginUserDto loginUserDto) async {
    try {
      final email = loginUserDto.email;
      final userExists = await getUserByEmail(email);
      if (userExists.isLeft) {
        throw const ServerException('Invalid email or password');
      }
      final user = userExists.right;
      final password = loginUserDto.password;
      final isPasswordCorrect =
          passwordHasherService.checkPassword(password, user.password);
      if (!isPasswordCorrect) {
        throw const ServerException('Invalid email or password');
      }
      return Right(user);
    } catch (e) {
      log(e.toString());
      return const Left(
        ServerFailure(
          message: 'Invalid email or password',
          statusCode: HttpStatus.unauthorized,
        ),
      );
    }
  }

Implementing createUser method

Similarly, we will implement createUser. We will check if the user with the given email exists. If the user exists, we will return an failure, else we will create the user and return it.

  @override
  Future<Either<Failure, User>> createUser(CreateUserDto createUserDto) async {
    try {
      final userExists = await getUserByEmail(createUserDto.email);
      if (userExists.isRight) {
        throw const ServerException('Email already in use');
      }
      // dto is already validated in the controller
      // we will hash the password here
      final hashedPassword = passwordHasherService.hashPassword(
        createUserDto.password,
      );
      final user = await dataSource.createUser(
        createUserDto.copyWith(
          password: hashedPassword,
        ),
      );
      return Right(user);
    } on ServerException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(message: e.message),
      );
    }
  }

Implementing JwtService

๐Ÿ” Securing User Login with JWT ๐Ÿ”’

Now, once we have all the necessary implementation of the repository and the data source, we will implement JwtService before implementing our UserController. This service will be used to create a JWT token for the user when they log in or signup.

In backend/lib/user/service/jwt_service.dart, we will create a new class called JwtService. We will use dart_jsonwebtoken package to create and verify JWT tokens.

flutter pub add dart_jsonwebtoken

Now, we will implement the JwtService class.

import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:dotenv/dotenv.dart';

class JWTService {
  const JWTService(this._env);

  final DotEnv _env;

  String sign(Map<String, dynamic> payload) {
    final secret = _env['JWT_SECRET']!;
    final jwt = JWT(payload);
    return jwt.sign(SecretKey(secret));
  }

  Map<String, dynamic> verify(String token) {
    final secret = _env['JWT_SECRET']!;
    final jwt = JWT.verify(token, SecretKey(secret));
    return jwt.payload as Map<String, dynamic>;
  }
}

๐Ÿ’ก NOTE: We are using dotenv package to get the JWT_SECRET from the .env file. ๐Ÿ’ก Make sure you have added the JWT_SECRET to the .env file.

...
JWT_SECRET=verycomplexsupersecret

Once this is done, we will implement the UserController.


Tidy Up Time ๐Ÿงน

Make Room for Improvement ๐Ÿง 

Remove duplicates

We have multiple routes that are not implemented and are returning the same response. We will remove these routes and create a new Handler to handle these requests.

We will create a new Handler called notAllowedRequestHandler in backend/lib/request_handlers/not_allowed_request_handler.dart to handle these requests.

Future<Response> notAllowedRequestHandler(RequestContext context) async {
  return Response.json(
    body: {'error': '๐Ÿ‘€ Looks like you are lost ๐Ÿ”ฆ'},
    statusCode: HttpStatus.methodNotAllowed,
  );
}

Then, we will replace the routes with this handler.

  • backend/routes/index.dart
import 'package:backend/request_handlers/not_allowed_request_handler.dart';
import 'package:dart_frog/dart_frog.dart';

Handler onRequest = notAllowedRequestHandler;
  • backend/routes/todos/index.dart

  • backend/routes/todos/[id].dart

+     return notAllowedRequestHandler(context);
-     return Response.json(
-       body: {'error': '๐Ÿ‘€ Looks like you are lost ๐Ÿ”ฆ'},
-       statusCode: HttpStatus.methodNotAllowed,
-     );

Refactor HttpController

Right now, if we have to implement HttpController, we will have to override all the methods. We will refactor the HttpController to make it optional to override all the methods.

we will create a new handler called unimplementedHandler in backend/lib/request_handlers/unimplemented_handler.dart to handle the unimplemented methods.

import 'dart:io';

import 'package:dart_frog/dart_frog.dart';

Future<Response> unimplementedHandler([RequestContext? context]) async {
  return Response.json(
    body: {'error': '๐Ÿ‘€ Not implemented yet'},
    statusCode: HttpStatus.notImplemented,
  );
}

Then, we will refactor the HttpController to use this handler. In backend/lib/controller/http_controller.dart, we will change the methods to call and return unimplementedHandler.

abstract class HttpController {
  FutureOr<Response> index(Request request) => unimplementedHandler();

  FutureOr<Response> store(Request request) => unimplementedHandler();

  FutureOr<Response> show(Request request, String id) => unimplementedHandler();

  FutureOr<Response> update(Request request, String id) =>
      unimplementedHandler();

  FutureOr<Response> destroy(Request request, String id) =>
      unimplementedHandler();
}

Implementing UserController

Adding magic with UserController Implementation ๐Ÿง™โ€โ™€๏ธ

Now, we will implement UserController in user/controller/user_controller.dart.

import 'dart:async';

import 'package:backend/controller/http_controller.dart';
import 'package:backend/services/jwt_service.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:repository/repository.dart';

class UserController extends HttpController {
  UserController(this._repo, this._jwtService);

  final UserRepository _repo;
  final JWTService _jwtService;

  @override
  FutureOr<Response> store(Request request) async {
    throw UnimplementedError();
  }

  FutureOr<Response> login(Request request) async {
    throw UnimplementedError();
  }
}

We will override store method, which will responsible for creating and storing the user, and then another login method, to log in the user.

Also, we will create a new private method called _signAndSendToken which will take the user, and then sign and send the user along with the JWT token.

Implement _signAndSendToken method

We will use this method once login and store method are successful and when we want to send a response to the user.

  Response _signAndSendToken(User user, [int? httpStatus]) {
    final token = _jwtService.sign(user.toJson());
    return Response.json(
      body: {
        'token': token,
        'user': user.toJson()..remove('password'),
      },
      statusCode: httpStatus ?? HttpStatus.ok,
    );
  }

Implement store method

We will parse the JSON body, and validate the body with CreateUserDto. Once validated, we will call repository.createUser which will hash the password and create a new user if not already there.

Once this is done, we will call _signAndSendToken and return with 201 status code.

  @override
  FutureOr<Response> store(Request request) async {
    final parsedBody = await parseJson(request);
    if (parsedBody.isLeft) {
      return Response.json(
        body: {'message': parsedBody.left.message},
        statusCode: parsedBody.left.statusCode,
      );
    }
    final json = parsedBody.right;
    final createTodoDto = CreateUserDto.validated(json);
    if (createTodoDto.isLeft) {
      return Response.json(
        body: {
          'message': createTodoDto.left.message,
          'errors': createTodoDto.left.errors,
        },
        statusCode: createTodoDto.left.statusCode,
      );
    }
    final res = await _repo.createUser(createTodoDto.right);
    return res.fold(
      (left) => Response.json(
        body: {'message': left.message},
        statusCode: left.statusCode,
      ),
      (right) => _signAndSendToken(right, HttpStatus.created),
    );
  }

Implement login

Similar to store, we will parse and verify LoginUserDto, and call repository.loginUser which will verify the password and return the user.

  FutureOr<Response> login(Request request) async {
    final parsedBody = await parseJson(request);
    if (parsedBody.isLeft) {
      return Response.json(
        body: {'message': parsedBody.left.message},
        statusCode: parsedBody.left.statusCode,
      );
    }
    final json = parsedBody.right;
    final loginUserDto = LoginUserDto.validated(json);
    if (loginUserDto.isLeft) {
      return Response.json(
        body: {
          'message': loginUserDto.left.message,
          'errors': loginUserDto.left.errors,
        },
        statusCode: loginUserDto.left.statusCode,
      );
    }
    final res = await _repo.loginUser(loginUserDto.right);
    return res.fold(
      (left) => Response.json(
        body: {'message': left.message},
        statusCode: left.statusCode,
      ),
      _signAndSendToken,
    );
  }

Adding Dependency Injections ๐Ÿงฐ

Making sure our ducks are in a row ๐Ÿฆ†

In our global middleware backend/routes/_middleware.dart, we will register the dependencies.

import 'package:backend/db/database_connection.dart';
import 'package:backend/services/jwt_service.dart';
import 'package:backend/services/password_hasher_service.dart';
import 'package:backend/user/controller/user_controller.dart';
import 'package:backend/user/data_source/user_data_source_impl.dart';
import 'package:backend/user/repositories/user_repository_impl.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:data_source/data_source.dart';
import 'package:dotenv/dotenv.dart';
import 'package:repository/repository.dart';

final env = DotEnv()..load();
final _db = DatabaseConnection(env);
final _userDs = UserDataSourceImpl(_db);
const _passwordHasher = PasswordHasherService();
final _userRepo = UserRepositoryImpl(_userDs, _passwordHasher);
final _jwtService = JWTService(env);
final _userController = UserController(_userRepo, _jwtService);

Handler middleware(Handler handler) {
  return handler
      .use(requestLogger())
      .use(provider<DatabaseConnection>((_) => _db))
      .use(provider<JWTService>((_) => _jwtService))
      .use(provider<UserDataSource>((_) => _userDs))
      .use(provider<UserRepository>((_) => _userRepo))
      .use(provider<UserController>((_) => _userController))
      .use(provider<PasswordHasherService>((_) => _passwordHasher));
}

Setting up routes

Connecting the dots ๐Ÿ”—๏ธ - defining API endpoints

We will now setup the routes for UserController in backend/routes/user we will create three new files index.dart, login.dart, and signup.dart.

Doing this we will have three different routes:

  • /user - index.dart

  • /user/login - login.dart

  • /user/signup - signup.dart

index.dart

This route does nothing. We will just return unimplementedHandler.

import 'package:backend/request_handlers/not_allowed_request_handler.dart';
import 'package:dart_frog/dart_frog.dart';

Handler onRequest = notAllowedRequestHandler;

login.dart

We will get the UserController from the RequestContext and call login method for POST requests.

import 'dart:async';

import 'package:backend/request_handlers/not_allowed_request_handler.dart';
import 'package:backend/user/controller/user_controller.dart';
import 'package:dart_frog/dart_frog.dart';

FutureOr<Response> onRequest(RequestContext context) {
  final userController = context.read<UserController>();
  if (context.request.method != HttpMethod.post) {
    return notAllowedRequestHandler(context);
  }
  return userController.login(context.request);
}

signup.dart

Similar to login.dart, we will get the UserController from the RequestContext and call store method for POST requests.

import 'dart:async';

import 'package:backend/request_handlers/not_allowed_request_handler.dart';
import 'package:backend/user/controller/user_controller.dart';
import 'package:dart_frog/dart_frog.dart';

FutureOr<Response> onRequest(RequestContext context) {
  final userController = context.read<UserController>();
  if (context.request.method != HttpMethod.post) {
    return notAllowedRequestHandler(context);
  }
  return userController.store(context.request);
}

๐Ÿ’ก NOTE: Recheck your .env file before testing the routes.


Testing routes

Testing Time ๐Ÿงช - Verify Your Routes!

Once we have set up the routes, we can test them using curl or postman.

/users/login

When the email and password match

  • REQUEST
curl -s -L -X POST 'http://localhost:8080/users/login' -H 'Content-Type: application/json' --data-raw '{
    "email":"saileshbro@gmail.com",
    "password":"6aMj@UBByu%7BzN^C9tMe#Te4b!4cJrXwwFi#HgKrQ&g&ddNN6eHQ94vd5SuJtEc%7^H6L^xews8soG@R7GnW*RvfJVMaKEuBXNtVtbP5!3^qs*n!Z%87q8eRJmKFUHg"
}'
  • RESPONSE
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjQ4ODY5Yjc4LWEwMjgtNGMwNS1hN2QzLTQ5OTQ2Mzk4NDI2NSIsIm5hbWUiOiJTYWlsZXNoIERhaGFsIiwiZW1haWwiOiJzYWlsZXNoQGdtYWlsLmNvbSIsImNyZWF0ZWRfYXQiOiIyMDIzLTAxLTMwVDA5OjU3OjQ5LjExODM2MVoiLCJwYXNzd29yZCI6IiQyYSQxMCRGSDVDc2N1Y0VuLlNwWlZmSWFtRGwuZzRMaDFhd2ltbVRMS05yU2NORW1qT25lSFZHOE4wLiIsImlhdCI6MTY3NTA2MDA0MH0.qvzLWgAEphlYqZztoBf7Bvag6hO1qkp44hUwl78CMVo",
  "user": {
    "id": "48869b78-a028-4c05-a7d3-499463984265",
    "name": "Sailesh Dahal",
    "email": "saileshbro@gmail.com",
    "created_at": "2023-01-30T09:57:49.118361Z"
  }
}

When the email or password does not match.

  • REQUEST
curl -s -L -X POST 'http://localhost:8080/users/login' -H 'Content-Type: application/json' --data-raw '{
    "email":"sa@gmail.com",
    "name":"Sailesh Dahal",
    "password":"6aMj@UBByu%7BzN^C9tMe#Te4b!4cJrXwwFi#HgKrQ&g&ddNN6eHQ94vd5SuJtEc%7^H6L^xews8soG@R7GnW*RvfJVMaKEuBXNtVtbP5!3^qs*n!Z%87q8eRJmKFUHg"
}'
  • RESPONSE
{ "message": "Invalid email or password" }

/users/signup

When the data is not valid

  • REQUEST
curl -s -L -X POST 'http://localhost:8080/users/signup' -H 'Content-Type: application/json' --data-raw '{
    "email":"sailes@gmail.com",
    "password":"6aMj@UBByu"
}'
  • RESPONSE
{
  "message": "Validation failed",
  "errors": {
    "name": ["Name is required"]
  }
}

When there is no user with the given email, it will create a new user and return the same response as /users/login.

  • REQUEST
curl -s -L -X POST 'http://localhost:8080/users/signup' -H 'Content-Type: application/json' --data-raw '{
    "email":"sailesh12@gmail.com",
    "name":"Sailesh Dahal",
    "password":"6aMj123"
}'
  • RESPONSE
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImM1ODE2YWQ0LTNkODctNDUzZC1hZmFkLTUwMzljY2YxZjdjNyIsIm5hbWUiOiJTYWlsZXNoIERhaGFsIiwiZW1haWwiOiJzYWlsZXNoMTJAZ21haWwuY29tIiwiY3JlYXRlZF9hdCI6IjIwMjMtMDEtMzBUMDY6Mjg6MzUuMzkyMjM4WiIsInBhc3N3b3JkIjoiJDJhJDEwJHRuTllGM3FHQmlPT1dKeHVacm1MaU91SHhBMU1HQlNMcmoxYS5ndHY2TTNCMHpmRW5Dc1dLIiwiaWF0IjoxNjc1MDYwMTE0fQ.aUpo9HXTFmdmkTsfGoTp0mcK0OJ_fFuwZArqyNFpKvQ",
  "user": {
    "id": "c5816ad4-3d87-453d-afad-5039ccf1f7c7",
    "name": "Sailesh Dahal",
    "email": "sailesh12@gmail.com",
    "created_at": "2023-01-30T06:28:35.392238Z"
  }
}

When there is a user with the given email, it will return the following response.

  • REQUEST
curl -s -L -X POST 'http://localhost:8080/users/signup' -H 'Content-Type: application/json' --data-raw '{
    "email":"sailesh12@gmail.com",
    "name":"Sailesh Dahal",
    "password":"6aMj123"
}'
  • RESPONSE
{
  "message": "Email already in use"
}

Protecting Routes

Locking Down: Secure Your Endpoints ๐Ÿ”’

Now, we can protect our routes by adding middleware to the routes. We will check for the Authorization header and verify the token. We can intercept all the requests going to /todos/* and check for the token.

We can do this by creating a new authorization middleware. We will create a new file backend/lib/middlewares/authorization_middleware.dart. Before that, let's create a new exception for unauthorized requests.

Creating UnauthorizedException

When the token is not valid, we will throw an UnauthorizedException which will be handled by the ExceptionHandlerMiddleware.

In exceptions package, we will create a new exception called UnauthorizedException in exceptions/lib/src/http_exception/unauthorized_exception.dart

import 'dart:io';

import 'package:exceptions/src/http_exception/http_exception.dart';

class UnauthorizedException extends HttpException {
  const UnauthorizedException({
    String message = 'Unauthorized',
    this.errors = const {},
  }) : super(message, HttpStatus.unauthorized);

  final Map<String, List<String>> errors;
}

๐Ÿ’ก Make sure to export the exception in http_exception.dart file.

export './bad_request_exception.dart';
export './not_found_exception.dart';
export './unauthorized_exception.dart';

๐Ÿ’ก Make sure to run flutter pub run build_runner build to generate the code.

Creating AuthorizationMiddleware

๐Ÿ”’ Securing Endpoints with AuthorizationMiddleware ๐Ÿ›ก๏ธ

Now, we can create the AuthorizationMiddleware in backend/lib/middlewares/authorization_middleware.dart.

import 'dart:io';

import 'package:backend/db/database_connection.dart';
import 'package:backend/services/jwt_service.dart';
import 'package:backend/todo/controller/todo_controller.dart';
import 'package:backend/todo/data_source/todo_data_source_impl.dart';
import 'package:backend/todo/repositories/todo_repository_impl.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:data_source/data_source.dart';
import 'package:exceptions/exceptions.dart';
import 'package:models/models.dart';
import 'package:repository/repository.dart';

Handler authorizationMiddleware(Handler handler) {
  return (context) async {
    try {
      final request = context.request;
      final authHeader = request.headers[HttpHeaders.authorizationHeader] ?? '';
      final token = authHeader.replaceFirst('Bearer ', '');
      if (token.isEmpty) throw const UnauthorizedException();
      final jwtService = context.read<JWTService>();
      final decoded = jwtService.verify(token);
      final decodedUser = User.fromJson(decoded);
      final userRepo = context.read<UserRepository>();
      final user = await userRepo.getUserById(decodedUser.id);
      if (user.isLeft) throw const UnauthorizedException();
      context = _handleAuthDependencies(context, user.right);
      return handler(context);
    } on UnauthorizedException catch (e) {
      return Response.json(
        body: {'message': e.message},
        statusCode: e.statusCode,
      );
    }
  };
}

RequestContext _handleAuthDependencies(
  RequestContext context,
  User user,
) {
  late RequestContext updatedContext;
  updatedContext = context.provide<User>(() => user);
  return updatedContext;
}

Here, we are checking for the Authorization header and verifying the token. If the token is valid, we are adding the User object to the context, so that we can get the logged-in user later from the provider.

If the token is invalid, we will return a 401 response.

Updating auth dependencies

Now, since we will have to add a user_id whenever, we will create a todo, we will have to pass the logged-in user to the TodoDataSource. We will update the TodoDataSourceImpl to accept the User object from the constructor.

class TodoDataSourceImpl implements TodoDataSource {
  /// {@macro todo_data_source_impl}
  const TodoDataSourceImpl(this._databaseConnection, this.user);
  final DatabaseConnection _databaseConnection;

  final User user;
}

Also, we will update createTodo and updateTodo methods to add the user_id.

Update createTodo method

  @override
  Future<Todo> createTodo(CreateTodoDto todo) async {
    try {
      await _databaseConnection.connect();
      final result = await _databaseConnection.db.query(
        '''
-       INSERT INTO todos (title, description, completed)
+       INSERT INTO todos (title, description, completed, user_id)
-       VALUES (@title, @description, @completed) RETURNING *
+       VALUES (@title, @description, @completed, @user_id) RETURNING *
        ''',
        substitutionValues: {
          'title': todo.title,
          'description': todo.description,
          'completed': false,
+         'user_id': _user.id,
        },
      );
      if (result.affectedRowCount == 0) {
        throw const ServerException('Failed to create todo');
      }
      final todoMap = result.first.toColumnMap();
      return Todo(
        id: todoMap['id'] as int,
+       userId: todoMap['user_id'] as String,
        title: todoMap['title'] as String,
        description: todoMap['description'] as String,
        createdAt: todoMap['created_at'] as DateTime,
      );
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

Update updateTodo method

      final result = await _databaseConnection.db.query(
        '''
        UPDATE todos
        SET title = COALESCE(@new_title, title),
            description = COALESCE(@new_description, description),
            completed = COALESCE(@new_completed, completed),
            updated_at = current_timestamp
        WHERE id = @id
+       AND user_id = @user_id
        RETURNING *
        ''',
        substitutionValues: {
          'id': id,
+         'user_id': _user.id,
          'new_title': todo.title,
          'new_description': todo.description,
          'new_completed': todo.completed,
        },
      );

Update Todo model to add userId

We will also add the missing userId from Todo model.

  factory Todo({
    required TodoId id,
+   required UserId userId,
    required String title,
    @Default('') String description,
    @Default(false) bool completed,
    @DateTimeConverter() required DateTime createdAt,
    @DateTimeConverterNullable() DateTime? updatedAt,
  }) = _Todo;

Now, once this is done, we will update the _handleAuthDependencies method in authorization_middleware.dart as

RequestContext _handleAuthDependencies(
  RequestContext context,
  User user,
) {
  final db = context.read<DatabaseConnection>();
  final todoDs = TodoDataSourceImpl(db, user);
  final todoRepo = TodoRepositoryImpl(todoDs);
  final todoController = TodoController(todoRepo);
  late RequestContext updatedContext;
  updatedContext = context.provide<User>(() => user);
  updatedContext = updatedContext.provide<TodoController>(() => todoController);
  updatedContext = updatedContext.provide<TodoRepository>(() => todoRepo);
  updatedContext = updatedContext.provide<TodoDataSource>(() => todoDs);
  return updatedContext;
}

Using AuthorizationMiddleware

Applying AuthorizationMiddleware to routes

Let's create a new middleware in backend/routes/todos/_middleware.dart and add the following code.

import 'package:backend/middlewares/authorization_middleware.dart';
import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) => authorizationMiddleware(handler);

Since this middleware lies inside routes/todos, all the /todos/* routes will be intercepted by this middleware. This means that all the /todos/* routes will be protected and if the user tries to access these routes without a valid token, it will return a 401 response.


Testing protected /todos/* routes

Putting updated routes to the Test ๐Ÿงช

If we try to access the /todos/* routes without a valid token, it will return a 401 response. We can test this by running the following command.

GET /todos

  • REQUEST
curl -s -L -X GET 'http://localhost:8080/todos'
  • RESPONSE
{
  "message": "Unauthorized"
}
  • REQUEST
curl -s -L -X GET 'http://localhost:8080/todos' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY5ZWFkZTg3LWRhNWUtNDRjZC1iNTRlLTQ3NGE4NWQ4ZGVlNCIsIm5hbWUiOiJTYWlsZXNoIERhaGFsIiwiZW1haWwiOiJzYWlsZXNoYnJvQGdtYWlsLmNvbSIsImNyZWF0ZWRfYXQiOiIyMDIzLTAxLTIzVDIyOjU0OjM4Ljg2NDkxMloiLCJwYXNzd29yZCI6IiQyYSQxMCR6VjBTZlI3cUpHOHlCelJWWThtNkRlMWo0UUtHL2VRdXl6NzduQ1lBL1luZENtb1ZSbzB0RyIsImlhdCI6MTY3NDQ5Mzc3OX0.W36mHXKFrxZDhz0Hrxt0Cmrz3WNVexiAZe2KAjrWMaE'
  • RESPONSE
[
  {
    "id": 76,
    "user_id": "69eade87-da5e-44cd-b54e-474a85d8dee4",
    "title": "this is a title asdf",
    "description": "Not so great description asdf",
    "completed": false,
    "created_at": "2023-01-23T22:56:31.686023Z",
    "updated_at": "2023-01-30T04:08:06.349549Z"
  }
]

POST /todos

  • REQUEST
curl -s -L -X POST 'http://localhost:8080/todos' -H 'Content-Type: application/json' --data-raw '{
    "title":"this is a title asdf",
    "description":"Not so great description asdf"
}'
  • RESPONSE
{
  "message": "Unauthorized"
}
  • REQUEST
curl -s -L -X POST 'http://localhost:8080/todos' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY5ZWFkZTg3LWRhNWUtNDRjZC1iNTRlLTQ3NGE4NWQ4ZGVlNCIsIm5hbWUiOiJTYWlsZXNoIERhaGFsIiwiZW1haWwiOiJzYWlsZXNoYnJvQGdtYWlsLmNvbSIsImNyZWF0ZWRfYXQiOiIyMDIzLTAxLTIzVDIyOjU0OjM4Ljg2NDkxMloiLCJwYXNzd29yZCI6IiQyYSQxMCR6VjBTZlI3cUpHOHlCelJWWThtNkRlMWo0UUtHL2VRdXl6NzduQ1lBL1luZENtb1ZSbzB0RyIsImlhdCI6MTY3NDQ5Mzc3OX0.W36mHXKFrxZDhz0Hrxt0Cmrz3WNVexiAZe2KAjrWMaE' -H 'Content-Type: application/json' --data-raw '{
    "title":"this is a title asdf",
    "description":"Not so great description asdf"
}'
  • RESPONSE
{
  "id": 79,
  "user_id": "69eade87-da5e-44cd-b54e-474a85d8dee4",
  "title": "this is a title asdf",
  "description": "Not so great description asdf",
  "completed": false,
  "created_at": "2023-01-30T07:10:10.102358Z",
  "updated_at": null
}

Similarly, we can test the other routes as well ๐Ÿฅณ


๐ŸŽ‰ Congrats, we've made it to the end of Part 6! ๐Ÿš€ We've successfully added user authentication with JWT to our dart_frog backend.

It's been an incredible adventure, and we've gone a long way since Part 1. Using Dart and Flutter, we created a full-stack to-do application. But our task does not end there. We haven't addressed testing yet in this course, but it's an important aspect of developing any program. Let us know in the comments if you'd want to learn more about testing your code in all of the modules we've written in this series.

As always, you can refer back to the GitHub repo for this tutorial at https://github.com/saileshbro/full_stack_todo_dart if you need any help. Don't forget to give it a โญ and help spread the word about the initiative. Also, if you get stuck or want assistance, please submit an issue or, better yet, contribute a pull request.

Thank you for joining me on this journey; let's create even more wonderful applications together. Have fun coding! ๐Ÿ’ป

ย