server: Protect routes with user authorization

This commit is contained in:
Elias Projahn 2020-05-07 22:12:39 +02:00
parent ef40b4cd06
commit 7aecbbba69
14 changed files with 334 additions and 11 deletions

View file

@ -15,5 +15,6 @@ Future<void> main() async {
); );
print('Database: ${config.dbPath ?? 'memory'}'); print('Database: ${config.dbPath ?? 'memory'}');
print('Server database: ${config.serverDbPath ?? 'memory'}');
print('Listening on ${config.host}:${config.port}'); print('Listening on ${config.host}:${config.port}');
} }

View file

@ -1,3 +1,4 @@
# A dbPath of null means that we want an in-memory database. # A dbPath and serverDbPath of null means that we want in-memory databases.
host: localhost host: localhost
port: 1833 port: 1833
secret: vulnerable

View file

@ -1,3 +1,5 @@
host: localhost host: localhost
port: 1833 port: 1833
dbPath: db.sqlite secret: vulnerable
dbPath: db.sqlite
serverDbPath: server.sqlite

176
server/lib/src/auth.dart Normal file
View file

@ -0,0 +1,176 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:aqueduct/aqueduct.dart';
import 'package:corsac_jwt/corsac_jwt.dart';
import 'package:steel_crypt/steel_crypt.dart';
import 'database.dart';
/// Information on the rights of the user making the request.
extension AuthorizationInfo on Request {
/// Whether the user may create new resources.
set mayUpload(bool value) => this.attachments['mayUpload'] = value;
bool get mayUpload => this.attachments['mayUpload'] ?? false;
/// Whether the user may edit existing resources.
set mayEdit(bool value) => this.attachments['mayEdit'] = value;
bool get mayEdit => this.attachments['mayEdit'] ?? false;
/// Whether the user may delete resources.
set mayDelete(bool value) => this.attachments['mayDelete'] = value;
bool get mayDelete => this.attachments['mayDelete'] ?? false;
}
/// A user as presented within a request.
class RequestUser {
/// The unique user name.
final String name;
/// An optional email address.
final String email;
/// The password in clear text.
final String password;
RequestUser({
this.name,
this.email,
this.password,
});
factory RequestUser.fromJson(Map<String, dynamic> json) => RequestUser(
name: json['name'],
email: json['email'],
password: json['password'],
);
}
/// Endpoint controller for user registration.
///
/// This expects a POST request with a JSON body representing a [RequestUser].
class RegisterController extends Controller {
final ServerDatabase db;
final _crypt = PassCrypt();
final _rand = Random.secure();
RegisterController(this.db);
@override
Future<Response> handle(Request request) async {
final json = await request.body.decode<Map<String, dynamic>>();
final requestUser = RequestUser.fromJson(json);
// Check if we already have a user with that name.
final existingUser = await db.getUser(requestUser.name);
if (existingUser != null) {
// Returning something different than 200 here has the security
// implication that an attacker can check for existing user names. At the
// moment, I don't see any alternatives, because we don't use email
// addresses for identification. The client needs to know, whether the
// user name is already given.
return Response.conflict();
} else {
final bytes = List.generate(32, (i) => _rand.nextInt(256));
final salt = base64UrlEncode(bytes);
final hash = _crypt.hashPass(salt, requestUser.password);
db.updateUser(User(
name: requestUser.name,
email: requestUser.email,
salt: salt,
hash: hash,
mayUpload: true,
mayEdit: false,
mayDelete: false,
));
return Response.ok(null);
}
}
}
/// Endpoint controller for user login.
///
/// This expects a POST request with a JSON body representing a [RequestUser].
class LoginController extends Controller {
final ServerDatabase db;
/// The secret that will be used for signing the token.
final String secret;
final _crypt = PassCrypt();
final JWTHmacSha256Signer _signer;
LoginController(this.db, this.secret) : _signer = JWTHmacSha256Signer(secret);
@override
Future<Response> handle(Request request) async {
if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>();
final requestUser = RequestUser.fromJson(json);
final realUser = await db.getUser(requestUser.name);
if (realUser != null) {
if (_crypt.checkPassKey(
realUser.salt, requestUser.password, realUser.hash)) {
final builder = JWTBuilder()
..expiresAt = DateTime.now().add(Duration(minutes: 30))
..setClaim('user', requestUser.name);
final token = builder.getSignedToken(_signer).toString();
return Response.ok(token);
}
}
return Response.unauthorized();
}
return Response(HttpStatus.methodNotAllowed, null, null);
}
}
/// Middleware for checking authorization.
///
/// This will set the fields defined in [AuthorizationInfo] on this request
/// according to the provided access token.
class AuthorizationController extends Controller {
final ServerDatabase db;
/// The secret that was used to sign the token.
final String secret;
final JWTHmacSha256Signer _signer;
AuthorizationController(this.db, this.secret)
: _signer = JWTHmacSha256Signer(secret);
@override
FutureOr<RequestOrResponse> handle(Request request) async {
final authHeaderValue =
request.raw.headers.value(HttpHeaders.authorizationHeader);
if (authHeaderValue != null) {
final authHeaderParts = authHeaderValue.split(' ');
if (authHeaderParts.length == 2 && authHeaderParts[0] == 'Bearer') {
final jwt = JWT.parse(authHeaderParts[1]);
if (jwt.verify(_signer)) {
final user = await db.getUser(jwt.claims['user']);
if (user != null) {
request.mayUpload = user.mayUpload;
request.mayEdit = user.mayEdit;
request.mayDelete = user.mayDelete;
}
}
}
}
return request;
}
}

View file

@ -7,7 +7,11 @@ class MusicusServerConfiguration extends Configuration {
String host; String host;
int port; int port;
String secret;
@optionalConfiguration @optionalConfiguration
String dbPath; String dbPath;
@optionalConfiguration
String serverDbPath;
} }

View file

@ -0,0 +1,19 @@
import 'package:moor/moor.dart';
part 'database.g.dart';
@UseMoor(include: {'database.moor'})
class ServerDatabase extends _$ServerDatabase {
@override
final schemaVersion = 0;
ServerDatabase(QueryExecutor e) : super(e);
Future<User> getUser(String name) async {
return await (select(users)..where((u) => u.name.equals(name))).getSingle();
}
Future<void> updateUser(User user) async {
await into(users).insert(user, mode: InsertMode.insertOrReplace);
}
}

View file

@ -0,0 +1,9 @@
CREATE TABLE users (
name TEXT PRIMARY KEY,
email TEXT,
salt TEXT NOT NULL,
hash TEXT NOT NULL,
may_upload BOOLEAN NOT NULL DEFAULT TRUE,
may_edit BOOLEAN NOT NULL DEFAULT FALSE,
may_delete BOOLEAN NOT NULL DEFAULT FALSE
);

View file

@ -1,6 +1,8 @@
import 'package:aqueduct/aqueduct.dart'; import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class EnsemblesController extends ResourceController { class EnsemblesController extends ResourceController {
final Database db; final Database db;
@ -26,6 +28,16 @@ class EnsemblesController extends ResourceController {
@Operation.put('id') @Operation.put('id')
Future<Response> putEnsemble( Future<Response> putEnsemble(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async { @Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.ensembleById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final ensemble = Ensemble.fromJson(json).copyWith( final ensemble = Ensemble.fromJson(json).copyWith(
id: id, id: id,
); );
@ -37,6 +49,10 @@ class EnsemblesController extends ResourceController {
@Operation.delete('id') @Operation.delete('id')
Future<Response> deleteEnsemble(@Bind.path('id') int id) async { Future<Response> deleteEnsemble(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteEnsemble(id); await db.deleteEnsemble(id);
return Response.ok(null); return Response.ok(null);
} }

View file

@ -1,6 +1,8 @@
import 'package:aqueduct/aqueduct.dart'; import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class InstrumentsController extends ResourceController { class InstrumentsController extends ResourceController {
final Database db; final Database db;
@ -26,6 +28,16 @@ class InstrumentsController extends ResourceController {
@Operation.put('id') @Operation.put('id')
Future<Response> putInstrument( Future<Response> putInstrument(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async { @Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.instrumentById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final instrument = Instrument.fromJson(json).copyWith( final instrument = Instrument.fromJson(json).copyWith(
id: id, id: id,
); );
@ -37,6 +49,10 @@ class InstrumentsController extends ResourceController {
@Operation.delete('id') @Operation.delete('id')
Future<Response> deleteInstrument(@Bind.path('id') int id) async { Future<Response> deleteInstrument(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteInstrument(id); await db.deleteInstrument(id);
return Response.ok(null); return Response.ok(null);
} }

View file

@ -1,6 +1,8 @@
import 'package:aqueduct/aqueduct.dart'; import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class PersonsController extends ResourceController { class PersonsController extends ResourceController {
final Database db; final Database db;
@ -26,6 +28,16 @@ class PersonsController extends ResourceController {
@Operation.put('id') @Operation.put('id')
Future<Response> putPerson( Future<Response> putPerson(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async { @Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.personById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final person = Person.fromJson(json).copyWith( final person = Person.fromJson(json).copyWith(
id: id, id: id,
); );
@ -37,6 +49,10 @@ class PersonsController extends ResourceController {
@Operation.delete('id') @Operation.delete('id')
Future<Response> deletePerson(@Bind.path('id') int id) async { Future<Response> deletePerson(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deletePerson(id); await db.deletePerson(id);
return Response.ok(null); return Response.ok(null);
} }

View file

@ -1,6 +1,8 @@
import 'package:aqueduct/aqueduct.dart'; import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class RecordingsController extends ResourceController { class RecordingsController extends ResourceController {
final Database db; final Database db;
@ -19,6 +21,16 @@ class RecordingsController extends ResourceController {
@Operation.put('id') @Operation.put('id')
Future<Response> putRecording( Future<Response> putRecording(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async { @Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.recordingById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final recordingInfo = RecordingInfo.fromJson(json); final recordingInfo = RecordingInfo.fromJson(json);
await db.updateRecording(recordingInfo); await db.updateRecording(recordingInfo);
@ -27,6 +39,10 @@ class RecordingsController extends ResourceController {
@Operation.delete('id') @Operation.delete('id')
Future<Response> deleteRecording(@Bind.path('id') int id) async { Future<Response> deleteRecording(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteRecording(id); await db.deleteRecording(id);
return Response.ok(null); return Response.ok(null);
} }

View file

@ -3,18 +3,22 @@ import 'dart:io';
import 'package:aqueduct/aqueduct.dart'; import 'package:aqueduct/aqueduct.dart';
import 'package:moor_ffi/moor_ffi.dart'; import 'package:moor_ffi/moor_ffi.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import 'package:musicus_server/src/work_recordings.dart';
import 'auth.dart';
import 'compositions.dart'; import 'compositions.dart';
import 'configuration.dart'; import 'configuration.dart';
import 'database.dart';
import 'ensembles.dart'; import 'ensembles.dart';
import 'instruments.dart'; import 'instruments.dart';
import 'persons.dart'; import 'persons.dart';
import 'recordings.dart'; import 'recordings.dart';
import 'works.dart'; import 'works.dart';
import 'work_recordings.dart';
class MusicusServer extends ApplicationChannel { class MusicusServer extends ApplicationChannel {
Database db; Database db;
ServerDatabase serverDb;
String secret;
@override @override
Future<void> prepare() async { Future<void> prepare() async {
@ -25,15 +29,35 @@ class MusicusServer extends ApplicationChannel {
} else { } else {
db = Database(VmDatabase.memory()); db = Database(VmDatabase.memory());
} }
if (config.serverDbPath != null) {
serverDb = ServerDatabase(VmDatabase(File(config.serverDbPath)));
} else {
serverDb = ServerDatabase(VmDatabase.memory());
}
secret = config.secret;
} }
@override @override
Controller get entryPoint => Router() Controller get entryPoint => Router()
..route('/persons/[:id]').link(() => PersonsController(db)) ..route('/login').link(() => LoginController(serverDb, secret))
..route('/register').link(() => RegisterController(serverDb))
..route('/persons/[:id]')
.link(() => AuthorizationController(serverDb, secret))
.link(() => PersonsController(db))
..route('/persons/:id/works').link(() => CompositionsController(db)) ..route('/persons/:id/works').link(() => CompositionsController(db))
..route('/instruments/[:id]').link(() => InstrumentsController(db)) ..route('/instruments/[:id]')
..route('/works/:id').link(() => WorksController(db)) .link(() => AuthorizationController(serverDb, secret))
.link(() => InstrumentsController(db))
..route('/works/:id')
.link(() => AuthorizationController(serverDb, secret))
.link(() => WorksController(db))
..route('/works/:id/recordings').link(() => WorkRecordingsController(db)) ..route('/works/:id/recordings').link(() => WorkRecordingsController(db))
..route('/ensembles/[:id]').link(() => EnsemblesController(db)) ..route('/ensembles/[:id]')
..route('/recordings/:id').link(() => RecordingsController(db)); .link(() => AuthorizationController(serverDb, secret))
.link(() => EnsemblesController(db))
..route('/recordings/:id')
.link(() => AuthorizationController(serverDb, secret))
.link(() => RecordingsController(db));
} }

View file

@ -1,6 +1,8 @@
import 'package:aqueduct/aqueduct.dart'; import 'package:aqueduct/aqueduct.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import 'auth.dart';
class WorksController extends ResourceController { class WorksController extends ResourceController {
final Database db; final Database db;
@ -19,6 +21,16 @@ class WorksController extends ResourceController {
@Operation.put('id') @Operation.put('id')
Future<Response> putWork( Future<Response> putWork(
@Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async { @Bind.path('id') int id, @Bind.body() Map<String, dynamic> json) async {
if (await db.workById(id).getSingle() != null) {
if (!request.mayEdit) {
return Response.forbidden();
}
} else {
if (!request.mayUpload) {
return Response.forbidden();
}
}
final workInfo = WorkInfo.fromJson(json); final workInfo = WorkInfo.fromJson(json);
await db.updateWork(workInfo); await db.updateWork(workInfo);
@ -27,6 +39,10 @@ class WorksController extends ResourceController {
@Operation.delete('id') @Operation.delete('id')
Future<Response> deleteWork(@Bind.path('id') int id) async { Future<Response> deleteWork(@Bind.path('id') int id) async {
if (!request.mayDelete) {
return Response.forbidden();
}
await db.deleteWork(id); await db.deleteWork(id);
return Response.ok(null); return Response.ok(null);
} }

View file

@ -3,10 +3,17 @@ description: A server hosting a Musicus database.
version: 0.0.1 version: 0.0.1
environment: environment:
sdk: ">=2.3.0 <3.0.0" sdk: ">=2.6.0 <3.0.0"
dependencies: dependencies:
aqueduct: aqueduct:
moor_ffi: corsac_jwt:
moor: ^3.0.2
moor_ffi: ^0.5.0
musicus_database: musicus_database:
path: ../database path: ../database
steel_crypt:
dev_dependencies:
build_runner:
moor_generator: ^3.0.0