Skip to content

JWT authentication

Let users register and log in, give them a JWT token, protect routes with it, and read the user in a controller.

What you need

  • A Dox app (dox create my_app). A new app has a database (SQLite), a User model and a users table with name, email and password columns.
  • The dox_auth package. A new app does not have it. Add it:
dart pub add dox_auth

1. Hide the password in the model

The token holds user.toJson(). Anyone with the token can read it. So the password hash must be in hidden. The model of a new app already does this:

lib/app/models/user/user.model.dart
import 'package:dox_orm/dox_orm.dart';

part 'user.model.g.dart';

@DoxModel(table: 'users', softDelete: true)
class User extends UserGenerator {
  @override
  List<String> get hidden => <String>['password'];

  @Column()
  String? name;

  @Column()
  String? email;

  @Column()
  String? password;
}

2. Set up the auth provider

lib/providers/auth_provider.dart
import 'package:dox_auth/dox_auth.dart';
import 'package:dox_core/dox_core.dart';
import 'package:my_app/app/models/user/user.model.dart';
import 'package:my_app/config/env.dart';

class AppAuthProvider extends DoxProvider {
  @override
  void setup() {
    Auth.initialize(
      AuthConfig(
        defaultGuard: 'api',
        guards: <String, AuthGuard>{
          'api': AuthGuard(
            driver: JwtAuthDriver(
              // APP_KEY signs the tokens. A new key logs every user out.
              secret: SecretKey(env.appKey),
              // a token stops working after this time
              expiresIn: const Duration(days: 7),
            ),
            provider: AuthProvider(model: () => User()),
          ),
        },
      ),
    );
  }
}

APP_KEY must be at least 32 bytes. dox key:generate makes a key of this length. A shorter key stops the app in production, and prints a warning in development.

Dox finds the provider by itself. Run dart run build_runner build and it is in lib/generated/container.g.dart.

3. Write the DTOs

@Unique makes sure the email is not taken. It comes from dox_orm, so hide its Schema class.

lib/app/dtos/register_user/register_user.dto.dart
import 'package:dox_core/dox_core.dart';
import 'package:dox_orm/dox_orm.dart' hide Schema;

part 'register_user.dto.g.dart';

@Dto()
class RegisterUser {
  @Length(min: 2, max: 50)
  @Trim()
  final String name;

  @Trim()
  @Lowercase()
  @Email()
  @Unique('users', column: 'email')
  final String email;

  @MinLength(8)
  final String password;

  const RegisterUser({
    required this.name,
    required this.email,
    required this.password,
  });

  static const DtoSchema<RegisterUser> schema = $RegisterUserDto;
}
lib/app/dtos/login_user/login_user.dto.dart
import 'package:dox_core/dox_core.dart';

part 'login_user.dto.g.dart';

@Dto()
class LoginUser {
  @Trim()
  @Lowercase()
  @Email()
  final String email;

  final String password;

  const LoginUser({required this.email, required this.password});

  static const DtoSchema<LoginUser> schema = $LoginUserDto;
}

4. Write the controller

lib/app/http/controllers/auth.controller.dart
import 'package:dox_auth/dox_auth.dart';
import 'package:dox_core/dox_core.dart';
import 'package:my_app/app/dtos/login_user/login_user.dto.dart';
import 'package:my_app/app/dtos/register_user/register_user.dto.dart';
import 'package:my_app/app/models/user/user.model.dart';

@Inject()
class AuthController {
  /// POST /api/auth/register
  Future<Map<String, dynamic>> register(RegisterUser body) async {
    User user = User()
      ..name = body.name
      ..email = body.email
      ..password = await Hash.makeAsync(body.password);
    await user.save();

    String? token = Auth().login(user);
    return <String, dynamic>{'token': token, 'user': user};
  }

  /// POST /api/auth/login
  Future<Map<String, dynamic>> login(LoginUser body) async {
    Auth auth = Auth();
    String? token = await auth.attempt(<String, dynamic>{
      'email': body.email,
      'password': body.password,
    });
    if (token == null) {
      throw UnAuthorizedException(message: 'Wrong email or password');
    }
    return <String, dynamic>{'token': token, 'user': auth.user<User>()};
  }

  /// GET /api/auth/me (behind AuthMiddleware)
  User? me(DoxRequest req) {
    return req.auth?.user<User>();
  }

  /// POST /api/auth/logout (behind AuthMiddleware)
  /// By default a JWT is not cancelled on the server. The client deletes
  /// the token, and it still works until `expiresIn` passes. To cancel it,
  /// turn on revocation and call `Auth().revokeToken(req)`.
  Map<String, String> logout(DoxRequest req) {
    return <String, String>{'message': 'Delete the token on the client'};
  }
}

Hash.make makes a bcrypt hash. auth.attempt finds the user by email and checks the password against that hash.

5. Add the routes

AuthMiddleware reads the Authorization: Bearer <token> header, checks the token and loads the user from the database. A missing or bad token stops the request with 401.

lib/routes/api.dart
import 'package:dox_auth/dox_auth.dart';
import 'package:dox_core/dox_core.dart';
import 'package:my_app/app/http/controllers/auth.controller.dart';

class ApiRouter extends Router {
  @override
  String get prefix => 'api';

  @override
  List<dynamic> get middleware => <dynamic>[];

  @override
  void register() {
    Route.post('/auth/register', (AuthController c) => c.register);
    Route.post('/auth/login', (AuthController c) => c.login);

    Route.middleware(<dynamic>[AuthMiddleware()], () {
      Route.get('/auth/me', (AuthController c) => c.me);
      Route.post('/auth/logout', (AuthController c) => c.logout);
      // put every protected route here
    });
  }
}

6. Register the DTOs

Dox finds a DTO in the dtos list of AppConfig. build_runner writes this list to lib/generated/dtos.g.dart. Add it once. Skip this step when your app.dart already has dtos: dtos.

lib/config/app.dart
import 'package:my_app/generated/dtos.g.dart';

AppConfig appConfig = AppConfig(
  dtos: dtos,
  // ...
);

7. Build and run

dart run build_runner build --delete-conflicting-outputs
dox serve

Check it

Register:

curl -H 'Content-Type: application/json' \
  -d '{"name":"Ann","email":"ann@example.com","password":"secret123"}' \
  http://localhost:3000/api/auth/register
{"token":"eyJhbGciOiJIUzI1NiIs...","user":{"id":1,"name":"Ann","email":"ann@example.com","created_at":"...","updated_at":"..."}}

Log in with a wrong password. You get 401:

{"error":{"code":"unauthorized","message":"Wrong email or password"}}

Log in and use the token:

TOKEN=$(curl -s -H 'Content-Type: application/json' \
  -d '{"email":"ann@example.com","password":"secret123"}' \
  http://localhost:3000/api/auth/login | jq -r .token)

curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/auth/me
{"id":1,"name":"Ann","email":"ann@example.com","created_at":"...","updated_at":"..."}

Without the header, /api/auth/me gives 401 Authentication failed.

The error shape

The {"error": ...} shape comes from the response handler in Custom errors. Without it, the 401 body is {"code":"unauthorized","message":"Wrong email or password"}.

Logout and expiry

By default the server checks only the signature, so it can not cancel one JWT. This is what you can do:

You want Do this
Log out on this device The client deletes the token. That is all logout does.
Limit the damage of a stolen token Keep expiresIn short, for example Duration(hours: 12).
Log every user out at once Change APP_KEY. Every old token stops working.
Block one user Delete the user. AuthMiddleware loads the user from the database on each request, so a deleted user gets 401.

To cancel one token, or all tokens of a user, turn on revocation. See Revoke tokens. Or use access tokens, which you can revoke at any time.

See also