Photo by James Sutton on Unsplash
The series of posts about advanced networking topics applied to Flutter apps continues. As always, this is meant for people who already have a good understanding of Flutter and know how to use Flutter widgets and the basics of the http
library.
That’s the kind of stuff you can learn by reading my Flutter book.
In particular, this post supposes you know how to make HTTP requests, how to use the most basic widgets (Scaffold
, Column
, Text
, FlatButton
, etc.) and that you know how to use a TextField
with a TextEditingController
, the basics of asynchronous functions and Future
s and how to use them with a FutureBuilder
, how to display dialogs and how to work with JSON.
You can learn about them online too, if you hate books. I won’t be judging you.
Regarding the backend, I’m going to suppose you know about how to use the Express Node.js framework, and that you know how to use basic SQL commands like CREATE
, INSERT
, and SELECT
.
The app we’re going to build is the simplest example of an app that requires authentication: it allows anyone to sign up, and any logged in user can access a piece of data. We’re going to implement the back-end with Node and the front-end with Flutter.
JWT (JSON Web Token) is a standard that specifies a very secure way to transmit session tokens between an user-accessible front-end (that we’ll write using Flutter) and a back-end (that we’ll write using Node).
Unlike a more traditional user session implementations, in which the session token is stored both on the server and on the client, the client sends the token along with every request and the server can check whether a session exists with that token for that user and decide whether to grant access based on that and what the user is actually allowed to do.
JWT is different. The server doesn’t store the token: at the time of authentication, it sends a signed token, but it doesn’t store it, instead relying on the signature it attaches to the token (obtained either with RSA, ECDSA or HMAC with SHA256 usually), which allows it to verify both the authenticity of the token and whether it was tampered with.
This means the token’s payload can contain both data the front-end needs, since it can be freely accessed by it, and data (like the user name/ID and/or an expiration date) the server needs to validate the request and the token.
The actual structure of the JWT is made of three base64-encoded strings separated by a .
character: the first contains information needed to verify the signature, the second contains the payload, the third contains the signature.
I’ve taken an example of a JWT generated by the backend we’ll build as an example in this post. It is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwiaWF0IjoxNTgxOTY2MzkxLCJleHAiOjE1ODMyNjIzOTF9.IDXKR0PknG96OyVgRf7NEX1olzhhLAiwE_-v-uMbOK0
.
If you pay attention, you’ll notice there are two dots. The first (eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
) can be decoded to ASCII to {"alg":"HS256","typ":"JWT"}
, which is a JSON object, which can be formatted like this:
{
"alg": "HS256",
"typ": "JWT"
}
HS256
is short for HMAC
+SHA256
, and typ
is quite self-explanatory, it tells us what the string we’re looking at is.
The second string (eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwiaWF0IjoxNTgxOTY2MzkxLCJleHAiOjE1ODMyNjIzOTF9
) can be decoded and formatted to:
{
"username": "username",
"iat": 1581966391,
"exp": 1583262391
}
This is a JWT for an user called username
, issued at (iat
) second 1581966391 after the Unix epoch (the 17th of February 2020 at 19:06) and that expires at (exp
) second 1583262391 (03/03/2020 at the same time as when it was created).
The third string is just the signature obtained as an HMAC with SHA256.
Our backend is going to have three routes:
/signup
, which accepts POST requests in urlencoded format, containing two self-explanatory text fields: an username field and a password field and either responds with status code 201 if it was able to create the user, or status code 409 if it wasn’t;/login
, which accepts POST requests in urlencoded format and accepts the same fields as /signup
, and either responds with status code 200 and the JWT in the body of the response, or with status code 401 if there is no user with the given username and password;/data
, which accepts GET requests, which must have a JWT attached to the Authorization
request header, and which will either return the “secret data” only authenticated users can access (with status code 200) or a response with status code 401, meaning the JWT is invalid or has expired.You can find the code for this Flutter app on GitHub by clicking here.
The flutter_secure_storage package is very easy to use: it’s simply key-value pair storage, but using iOS’s Keychain directly or by encrypting the data, storing the encrypted data in Android’s Shared Preferences.
You can write the myvalue and associate it to the mykey key using it by using storage.write()
in the following way:
var storage = FlutterSecureStorage();
storage.write(key: "mykey", value: "myvalue");
storage.write
is asynchronous (it has a return type of Future<void>
), so you should use await
if you need to pause execution while it executes.
You can read the value associated to the mykey key by using storage.read()
, which is also asynchronous, so you need to wait for it to return in order to be able to read the value.
var storage = FlutterSecureStorage();
var value = await storage.read(key: "mykey");
You can use storage.delete()
to delete a value (it’s also asynchronous):
var storage = FlutterSecureStorage();
storage.delete(key: "mykey");
Two methods exist, called readAll()
and deleteAll()
(both asynchronous), which respectively return a Map
of all the stored values and delete all of the stored values.
The Flutter app doesn’t need to be particularly complicated to be able to work with JWT: it’s mostly about writing an authentication flow, storing the JWT token and sending it with each request.
In addition to that, for this example we’ll check whether the token has expired on the front-end and we’ll show the username after the user logs in, so we’ll actually have to decode the payload. We are going to store the JWT using the flutter_secure_storage package, which is the simplest way to access the secure storage interfaces provided by iOS and Android from a Flutter app.
Here are two screenshots of what we want to achieve:
Add the flutter_secure_storage and http packages to the app’s dependencies in pubspec.yaml
:
Then, set the minimum supported Android version to SDK level 18 (Android 4.3) because that’s required by flutter_secure_storage.
Create the usual lib/main.dart
, import the packages, initialize a FlutterSecureStorage
object and insert the IP and port where the Node server backend will be running, so that we can use that going forward without having to change anything else from the example code I provided:
The structure of our Flutter app is going to be the following:
MyApp
class, which is going to check whether the user has previously logged in, and decide whether to run the LoginPage
or the HomePage
;LoginPage
is where we are going to allow the user to log in or sign up;HomePage
is where we are going to show the user the secret data that can only be accessed by logged-in users. The HomePage
needs to be able to be constructed from either just the JWT Base 64 stringThe MaterialApp
object we’re launching is called MyApp
, but we’ll worry about that later, given that it needs to check whether we’re already logged in, and then choose whether to display a log-in page or the home page.
That’s a bit boring to worry about now, let’s build some UI and create a log-in page!
The log-in page itself will be a StatelessWidget
called LoginPage
:
class LoginPage extends StatelessWidget {
}
The nature of the login page and our implementation means we are going to make extensive use of dialogs. This means we’ll create a helper method in order to make everything more succint:
void displayDialog(BuildContext context, String title, String text) =>
showDialog(
context: context,
builder: (context) =>
AlertDialog(
title: Text(title),
content: Text(text)
),
);
We’ll also create methods that attempt to login and signup the user, and they’re very simple POST requests, as we saw earlier on when we talked about our app’s API interface.
We’ll return a string for the login method, which will be null
in case of an error (i.e. wrong username/password) and the JWT if the authentication process succeded:
Future<String> attemptLogIn(String username, String password) async {
var res = await http.post(
"$SERVER_IP/login",
body: {
"username": username,
"password": password
}
);
if(res.statusCode == 200) return res.body;
return null;
}
The sign-up method doesn’t actually have to return anything useful to the app, so we can just return the status code and deal with that later to establish whether the operation was successuful or not:
Future<int> attemptSignUp(String username, String password) async {
var res = await http.post(
'$SERVER_IP/signup',
body: {
"username": username,
"password": password
}
);
return res.statusCode;
}
Here comes the fun part!
@override
Widget build(BuildContext context) =>
Scaffold(
appBar: AppBar(title: Text("Log In")),
body: /* TODO:INSERT BODY HERE */
);
what’s the body
going to be? A Column
with two TextFields
that allow the user to insert username and password and two FlatButton
s: one to log in and one to sign up. We’ll also add some padding around it given that it looks horrible to my eyes without it:
body: Padding(
padding: EdgeInsets.all(8.0),
child: Column(
children: [
TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username'
),
),
TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password'
),
FlatButton(
child: Text("Log In"),
onPressed: /* TODO:HANDLE LOG IN */
),
FlatButton(
child: Text("Sign Up"),
onPressed: /* TODO:HANDLE SIGN UP */
)
)
]
)
)
The labelText
in the InputDecoration
is the nicest looking way to tell users what each TextField
is for.
Define the _usernameController
and _passwordController
TextEditingController
s somewhere in the class definition, like this:
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
Let’s worry about the function to handle logging in, which needs to call the attemptLogIn
method with the username
and password
taken from the TextEditingController
s, then check the JWT returned by the attemptLogIn
method. If it is null
, we need to display a dialog to inform the user we were unable to log them in. If it isn’t, we can switch to the HomePage
and save the JWT:
onPressed: () async {
var username = _usernameController.text;
var password = _passwordController.text;
var jwt = await attemptLogIn(username, password);
if(jwt != null) {
storage.write(key: "jwt", value: jwt);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HomePage.fromBase64(jwt)
)
);
} else {
displayDialog(context, "An Error Occurred", "No account was found matching that username and password");
}
},
The function that handles sign-up is going to be complicated by the checking of a few conditions.
We are going to check on the front-end that the user doesn’t try to use un username or password less than 4 characters long. This should also be done on the back-end, but this tutorial focuses more on Flutter than it does on Node, so we’re just going to do this on the front-end.
If those two inputs are valid, we are going to attempt to sign up, and check the response. If we get HTTP status code 201, it means the user was created and we can simply tell the user to log in with those credentials.
If we get HTTP status code 409, it means the username is already in use, and we can tell that to the user. If the response’s status code is neither 409 nor 201, the request failed (probably because of a network error or an internal server error), we just tell the user an unknown error occurred:
onPressed: () async {
var username = _usernameController.text;
var password = _passwordController.text;
if(username.length < 4)
displayDialog(context, "Invalid Username", "The username should be at least 4 characters long");
else if(password.length < 4)
displayDialog(context, "Invalid Password", "The password should be at least 4 characters long");
else{
var res = await attemptSignUp(username, password);
if(res == 201)
displayDialog(context, "Success", "The user was created. Log in now.");
else if(res == 409)
displayDialog(context, "That username is already registered", "Please try to sign up using another username or log in if you already have an account.");
else {
displayDialog(context, "Error", "An unknown error occurred.");
}
}
},
The entire LoginPage
definition looks like this in the end:
We already know what we need as constructors for the HomePage
. Getting the payload from the base64 JWT string should be pretty self-explanatory if you understood the section at the start of this post about the structure of a JWT, and you only need to keep in mind that base64.decode needs a padded base64 string, and that can be obtained with base64.normalize()
:
class HomePage extends StatelessWidget {
HomePage(this.jwt, this.payload);
factory HomePage.fromBase64(String jwt) =>
HomePage(
jwt,
json.decode(
ascii.decode(
base64.decode(base64.normalize(jwt.split(".")[1]))
)
)
);
final String jwt;
final Map<String, dynamic> payload;
}
The HomePage
widget itself is going to be made of a FutureBuilder
that waits for the GET request to the server to get the data, and then either displays it or some text informing the user an error has occurred, if that is the case:
@override
Widget build(BuildContext context) =>
Scaffold(
appBar: AppBar(title: Text("Secret Data Screen")),
body: Center(
child: FutureBuilder(
future: http.read('$SERVER_IP/data', headers: {"Authorization": jwt}),
builder: (context, snapshot) =>
snapshot.hasData ?
Column(
children: <Widget>[
Text("${payload['username']}, here's the data:"),
Text(snapshot.data, style: Theme.of(context).textTheme.display1)
],
)
:
snapshot.hasError ? Text("An error occurred") : CircularProgressIndicator()
),
),
);
The HomePage
class definition is, therefore, the following:
The MyApp
class is the class that gets run when the app starts. It needs to check whether it already has a JWT and, if it has one, it should check whether it is valid, whether it has expired and, based on that, decide whether to ask the user to log in or whether to show them the home page. We are going to need to create a method called jwtOrEmpty()
Future<String> get jwtOrEmpty async {
var jwt = await storage.read(key: "jwt");
if(jwt == null) return "";
return jwt;
}
That’s because the FutureBuilder
’s snapshot.hasData
parameter that gets passed to the builder
function would return false
if it were to receive a null
value, which would be the case if the JWT were non-existent. This is unwanted because we would have no way of distinguishing the case in which we are still waiting for the Future
to return and the case in which there is no JWT. Having this second case return an empty string instead of null
fixes the issue.
All that’s left to do is to create a build()
method, which should return a MaterialApp
(that’s the whole point of the MyApp
class) that, after getting the JWT and checking it, either shows the user the login page or the home page based on the criteria I described earlier.
All of that ends up in the following class definition:
You can find the code for this Node backend on GitHub by clicking here.
The Node back-end is where most of it happens: we need to create rules for login, logout and some sort of data to access. We are going to store users in an SQLite database to keep things simple for this example.
The token can be signed using either a method based on public key cryptography (for example using RSA or ECDSA) or by relying on hashing the concatenation of the secret key and the message (called a payload in JWT terms) with any hashing algorithm (usually sha256). The latter concept has been expanded into a full standard for generation of a digital signature (called HMAC) that is protected against collisions and length extension attacks, about which you can find more information on Wikipedia.
For this example, that’s what we will use: HMAC with SHA256. That’s because it’s easier for a quick example for a blog post because we have greater freedom with our choice of private key. When building a real app, you should obviously consider the advantages and disadvantages of this approach based on your needs and the requirements your apps needs to meet. For this post we’ll use a short and simple string as a key, that’s not how it’s supposed to be done: it should ideally be a generated pseudorandom (and particularly long) key, just like any RSA or ECDSA key generated with OpenSSL, otherwise you’re making it very easy for attackers to crack your private key and generate their own JWT tokens your back-end will think are genuine, allowing them to pretend they’re logged in as any user, completely breaking your website’s security.
In other words, this is an example meant to be as easy to follow as possible and you must take the appropriate precautions when it comes to choosing or generating a private key. Since we’re talking about security precautions, you should obviously use TLS for communications between front-end and back-end in production. Also, salt the passwords before hashing them if you really want to play it safe.
These are the libraries we are going to use:
The jsonwebtoken
NPM package is very, very easy to use. It provides us with three functions, once imported with jwt = require("jsonwebtoken")
:
jwt.sign(payload, key, options)
, which returns a JWT containing the payload
and signed using the key
, optionally you could also add a callback at the end of the argument list and it would be ran asynchronously, but we’re not going to use that feature for our simple example, the default algorithm used for generating the signature is RS256
(RSA signature with SHA256);jwt.verify(token, key)
, which returns the decoded payload if the JWT is valid, and throws an error if it isn’t, it also optionally takes some options and a callback just like jwt.sign()
, but we’re going to provide neither of them;jwt.decode(token)
, which decodes the payload without verifying the validity, we’re not going to use this one at all.For example, if your payload is {username: "myusername"}
and the private key is stored in the variable privKey
and you want to create a JWT using the provided RSA private key that is valid for two hours, you can run
let jwt = jwt.sign({username: "myusername"}, privKey, {expiresIn: "2h"} );
and then, if pubKey
is the corresponding RSA public key, you can run
let jwt = jwt.verify({username: "myusername"}, pubKey);
In our example we are going to use just one secret key (stored in a variable called KEY
), so we are going to generate the JWT as an HMAC with SHA256 (HS256) and have it expire after 15 days, which means we are going to generate the JWT with:
let jwt = jwt.sign({username: "myusername"}, KEY, {expiresIn: "15d", algorithm: "HS256"});
I recommend you look at the documentation for the package on the NPM page for a more complete list of options. I’m not writing an API reference here, I’m trying to make you understand how to implement this.
The sqlite3 Node SQLite Driver doesn’t come with too many bells and whistles. There are other packages that provide different interfaces, this is the most basic one and it’s perfect for a very basic and simple example that really shouldn’t require a huge node_modules
directory or particularly complicated.
After importing the library with sqlite = require("sqlite3")
, you can initialize a connection to a database stored in a file called filename.db with:
let db = new sqlite.Database("filename.db");
The functions we are going to use are:
db.get(query, function(err, row) {})
, which runs the query
and passes to the provided callback the first row returned as the row
argument and any errors as the err
argument;db.run(query)
, which runs the given query, without returning any results, this is used for commands such as CREATE
and INSERT
and you can pass a callback to be called in case of errors.Two other functions exist called db.all()
and db.each()
which respectively pass all of the rows as an array to the callback and call the callback for each row returned, only passing one at a time. We would need to use such functions if we wanted, for example, to check whether a log-in attempt failed because the password was wrong, even though the given username exists in the database.
The queries can contain parameters, which can optionally be passed as an array, so they can replace ?
characters found in the query string. This will sanitize the strings before substituting them, not making you vulnerable to SQL injection. For example, the following two calls to db.each
are equivalent:
db.each("SELECT * FROM Users WHERE username='carmine'", function(err, row) {
console.log(row);
});
db.each("SELECT * FROM Users WHERE username=?", ['carmine'], function(err, row) {
console.log(row);
});
As always, create a Node project with
$ npm init
and install the packages I listed above with
$ npm install --save express jsonwebtoken sqlite3
We are going to import all of the libraries into our code and set the secret key (a very unsafe one, but this is just an example) and initialize the database connection:
You can do this either directly using the SQLite or any other tool of your choice or by creating this Node.js file and running it:
Signing users up is simple and has nothing to do with how we manage authorization: we’re not logging them in, which means we just need to check whether they have already signed up. If they haven’t, we sign them up by adding them to our database. We are going to use the Express built-in urlencoded middleware to parse the body of the request and we’re going to log everything to the server console:
Logging in is all about looking in the database for the user who is trying to log in, generating a JWT and returning it if we find it, and returning an error if we don’t:
Verifying the token is very easy with the Node library we are using, meaning the /data
route is written very quickly and painlessly, remembering that a failure in verifying the JWT will result in an error being thrown:
As usual, we take the port to run the server on from the environment variable PORT
and, if that fails, we set it to 3000 and run the server: