๐ 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
Table of contents
- Overview ๐
- Updating Database ๐ ๏ธ
- Create User model
- Creating UserDataSource
- Creating UserRepository
- Implementing UserDataSource
- Creating PasswordHasherService
- Implementing UserRepository
- Implementing JwtService
- Tidy Up Time ๐งน
- Implementing UserController
- Adding Dependency Injections ๐งฐ
- Setting up routes
- Testing routes
- Protecting Routes
- Testing protected /todos/* routes
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
andUserDataSource
.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:
/todos*
- Guard all to-do routes withAuthorization
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"
}'
/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"
}'
/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
: uniqueuuid
name
: name of the useremail
: email of the userpassword
: hashed password of the usercreated_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
andlogin_user_dto
fromuser.dart
, anduser.dart
frommodels.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
fromrepository.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 theJWT_SECRET
from the.env
file. ๐ก Make sure you have added theJWT_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! ๐ป