10 · Capstone — Authenticated Notes API¶
This capstone pulls together the whole level: shelf routing and databases from Level 3, plus security (salted password hashing, secure tokens), production logging, and middleware-based auth from this level. It's a small but real service: users sign up, log in, and manage their own private notes.
Auth: salted hashes and bearer tokens¶
Following module 04: every password gets its own random salt, and a successful login mints an opaque random token (not a real JWT — no signing, no expiry — a deliberate simplification called out in the stretch goals) mapped to a user id in memory.
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:sqlite3/sqlite3.dart';
String _generateSalt() {
final random = Random.secure();
return base64Url.encode(List<int>.generate(16, (_) => random.nextInt(256)));
}
String _hash(String password, String salt) =>
sha256.convert(utf8.encode('$salt:$password')).toString();
class AuthService {
AuthService(this._db) {
_db.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
salt TEXT NOT NULL,
password_hash TEXT NOT NULL
);
''');
}
final Database _db;
final Map<String, int> _tokens = {}; // token -> userId
({int id, String error})? signUp(String username, String password) {
final salt = _generateSalt();
final hash = _hash(password, salt);
try {
final stmt = _db.prepare(
'INSERT INTO users (username, salt, password_hash) VALUES (?, ?, ?)');
stmt.execute([username, salt, hash]);
stmt.dispose();
return (id: _db.lastInsertRowId, error: '');
} on SqliteException {
return (id: -1, error: 'username taken'); // UNIQUE constraint hit
}
}
String? logIn(String username, String password) {
final rows = _db.select('SELECT id, salt, password_hash FROM users WHERE username = ?', [username]);
if (rows.isEmpty) return null;
final row = rows.first;
if (_hash(password, row['salt'] as String) != row['password_hash']) return null;
final token = base64Url.encode(List<int>.generate(24, (_) => Random.secure().nextInt(256)));
_tokens[token] = row['id'] as int;
return token;
}
int? userIdForToken(String? token) => token == null ? null : _tokens[token];
}
Catching SqliteException specifically around the insert (rather than
checking for an existing username first) avoids a race between "check" and
"insert" — the database's own UNIQUE constraint is the actual source of
truth for uniqueness, same lesson as module 03 of Level 3.
Notes: private per-user data¶
class NoteService {
NoteService(this._db) {
_db.execute('''
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
body TEXT NOT NULL
);
''');
}
final Database _db;
Map<String, dynamic> createNote(int userId, String body) {
final stmt = _db.prepare('INSERT INTO notes (user_id, body) VALUES (?, ?)');
stmt.execute([userId, body]);
stmt.dispose();
return {'id': _db.lastInsertRowId, 'body': body};
}
List<Map<String, dynamic>> notesFor(int userId) {
final rows = _db.select('SELECT id, body FROM notes WHERE user_id = ? ORDER BY id', [userId]);
return rows.map((r) => {'id': r['id'], 'body': r['body']}).toList();
}
}
Every query filters by user_id — there is no "get all notes" route.
Forgetting that WHERE user_id = ? on any future route would leak every
user's notes to every other user; it's the single most important line in
this whole service.
Middleware: authenticating every /notes request in one place¶
Rather than checking the token inside every notes handler, one middleware
wraps the whole /notes sub-router and stores the authenticated user id on
Request.context for handlers to read.
import 'package:shelf/shelf.dart';
Middleware requireAuth(AuthService auth) {
return (Handler inner) {
return (Request request) {
final header = request.headers['authorization'];
final token = header?.startsWith('Bearer ') == true ? header!.substring(7) : null;
final userId = auth.userIdForToken(token);
if (userId == null) {
return Response(401, body: jsonEncode({'error': 'unauthorized'}));
}
return inner(request.change(context: {'userId': userId}));
};
};
}
final notesRouter = Router()
..get('/', (Request req) {
final userId = req.context['userId'] as int;
return Response.ok(jsonEncode(notes.notesFor(userId)));
})
..post('/', (Request req) async {
final userId = req.context['userId'] as int;
final body = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
return Response(201, body: jsonEncode(notes.createNote(userId, body['body'] as String)));
});
router.mount('/notes', const Pipeline().addMiddleware(requireAuth(auth)).addHandler(notesRouter.call));
router.mount attaches a whole sub-router (with its own middleware
pipeline) under a path prefix — every route inside notesRouter gets
requireAuth applied without repeating it per-route.
Running it¶
Driving the running server end to end — sign up, a duplicate signup, log in, an unauthenticated request, then an authenticated create-and-list:
{"timestamp":"...","level":"info","message":"listening","port":8098}
POST /signup -> 201 {"id":1}
POST /signup -> 409 {"error":"username taken"}
POST /login -> 200 {"token":"F9Ak5uHMVmDCUMs_u_7o4OGsZ44tSpAK"}
GET /notes -> 401 {"error":"unauthorized"}
GET /notes -> 200 []
POST /notes -> 201 {"id":1,"body":"Buy milk"}
GET /notes -> 200 [{"id":1,"body":"Buy milk"}]
The unauthenticated GET /notes is rejected by requireAuth before ever
reaching NoteService; the authenticated calls after login see an
initially-empty list, then the note they just created — end-to-end proof
that auth, storage, and per-user scoping all work together correctly.
How It Actually Works¶
Salted hashes for auth in this capstone rely on the same mechanism covered in the security-practices lesson: a slow, memory-hard hash function (bcrypt/Argon2) combined with a unique per-user random salt means each password verification is a deliberately expensive computation performed once per login attempt — the server re-derives the hash from the submitted password plus the stored salt and compares it to the stored hash, never storing or transmitting the plaintext password itself after registration.
Bearer tokens work as a stateless-to-the-network, stateful-on-the-server
mechanism: the token itself (whether a random opaque string looked up in a
sessions table, or a self-contained signed JWT) is just an artifact the
client re-presents on every request; the middleware that authenticates
every /notes request in one place is exactly the shelf middleware-as-
function-composition pattern from the APIs lesson — a wrapping Handler
that inspects the Authorization header before the inner route handler
ever runs, short-circuiting with a 401 Response if the token doesn't
resolve to a valid session/user, so no individual route handler needs to
duplicate that check.
"Private per-user data" is enforced at the database-query level, not by trusting client input: every notes query the service layer runs is scoped by the authenticated user's ID extracted from the validated token — not from any user ID the client could put in a request body or URL — which is what actually prevents one authenticated user from reading another user's notes by guessing or manipulating an ID, closing the same class of trust-boundary mistake the path-traversal lesson covered for filesystem paths.
Stretch goals¶
- Replace the in-memory
_tokensmap with asessionstable (token, user_id, expires_at) so tokens survive a process restart and can actually expire — as-is, every token is invalidated the moment the process restarts, and none of them ever expire on their own. - Add rate limiting to
/login(e.g. aMap<String, int>of failed attempts per username with a cooldown) to blunt brute-force password guessing. - Add
PATCH /notes/<id>andDELETE /notes/<id>, making sure both checkuser_idmatches the authenticated user — not just that the note id exists — before allowing the edit or delete. - Package it with the Docker module's
multi-stage pattern, reading
PORTfrom the environment and binding toInternetAddress.anyIPv4. - Add a
dart_test.yaml-tagged test suite (module 05) coveringAuthServiceandNoteServicedirectly against an in-memory database, plus a GitHub Actions workflow that runs it on every push. - Swap the hand-rolled
sha256-with-salt hashing for a real password hashing library (bcrypt/Argon2 via a suitable package) as flagged in module 04 — this capstone's hashing is good enough to demonstrate the pattern, not production-grade on its own.