import 'dart:async'; import 'dart:io'; import 'package:aqueduct/aqueduct.dart'; import 'package:corsac_jwt/corsac_jwt.dart'; import 'compute.dart'; import 'crypt.dart'; import 'database.dart'; /// Information on the user making the request. extension AuthorizationInfo on Request { /// The username of the logged in user. /// /// If this is a non null value, the user was authenticated. String get username => this.attachments['username']; set username(String value) => this.attachments['username'] = value; /// Whether the user may create new resources. /// /// This can only be true if the user was authenticated. bool get mayUpload => this.attachments['mayUpload'] ?? false; set mayUpload(bool value) => this.attachments['mayUpload'] = value; /// Whether the user may edit existing resources. /// /// This can only be true if the user was authenticated. bool get mayEdit => this.attachments['mayEdit'] ?? false; set mayEdit(bool value) => this.attachments['mayEdit'] = value; /// Whether the user may delete resources. /// /// This can only be true if the user was authenticated. bool get mayDelete => this.attachments['mayDelete'] ?? false; set mayDelete(bool value) => this.attachments['mayDelete'] = value; } /// Endpoint controller for user registration. /// /// This expects a POST request with a JSON body representing a [RequestUser]. class RegisterController extends Controller { final ServerDatabase db; RegisterController(this.db); @override Future handle(Request request) async { if (request.method == 'POST') { final json = await request.body.decode>(); final String username = json['username']; final String email = json['email']; final String password = json['password']; // Check if we already have a user with that name. final existingUser = await db.getUser(username); 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 { // This will take a long time, so we run it in a new isolate. final result = await compute(Crypt.hashPassword, password); db.updateUser(User( name: username, email: email, salt: result.salt, hash: result.hash, mayUpload: true, mayEdit: false, mayDelete: false, )); return Response.ok(null); } } else { return Response(HttpStatus.methodNotAllowed, null, 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 JWTHmacSha256Signer _signer; LoginController(this.db, this.secret) : _signer = JWTHmacSha256Signer(secret); @override Future handle(Request request) async { if (request.method == 'POST') { final json = await request.body.decode>(); final String username = json['username']; final String password = json['password']; final user = await db.getUser(username); if (user != null) { // We check the password in a new isolate, because this can take a long // time. if (await compute( Crypt.checkPassword, CheckPasswordRequest( password: password, salt: user.salt, hash: user.hash, ), )) { final builder = JWTBuilder() ..expiresAt = DateTime.now().add(Duration(minutes: 30)) ..setClaim('user', username); final token = builder.getSignedToken(_signer).toString(); return Response.ok(token, headers: {'Content-Type': 'text/plain'}); } } return Response.unauthorized(); } return Response(HttpStatus.methodNotAllowed, null, null); } } /// An endpoint controller for retrieving and changing account details. class AccountDetailsController extends Controller { final ServerDatabase db; AccountDetailsController(this.db); @override Future handle(Request request) async { if (request.method == 'GET') { if (request.username != null) { final user = await db.getUser(request.username); return Response.ok({ 'email': user.email, }); } else { return Response.forbidden(); } } else if (request.method == 'POST') { final json = await request.body.decode>(); final String username = json['username']; final String password = json['password']; final String newEmail = json['newEmail']; final String newPassword = json['newPassword']; final user = await db.getUser(username); // Check whether the user exists and the password was right. if (user != null && await compute( Crypt.checkPassword, CheckPasswordRequest( password: password, salt: user.salt, hash: user.hash, ), )) { HashPasswordResult hashResult; if (newPassword != null) { hashResult = await compute(Crypt.hashPassword, newPassword); } else { hashResult = HashPasswordResult( hash: user.hash, salt: user.salt, ); } db.updateUser(User( name: username, email: newEmail ?? user.email, salt: hashResult.salt, hash: hashResult.hash, mayUpload: user.mayUpload, mayEdit: user.mayEdit, mayDelete: user.mayDelete, )); return Response.ok(null); } else { return Response.forbidden(); } } else { return Response(HttpStatus.methodNotAllowed, null, null); } } } /// An endpoint controller for deleting an account. class AccountDeleteController extends Controller { final ServerDatabase db; AccountDeleteController(this.db); @override Future handle(Request request) async { if (request.method == 'POST') { final json = await request.body.decode>(); final String username = json['username']; final String password = json['password']; final user = await db.getUser(username); // Check whether the user exists and the password was right. if (user != null && await compute( Crypt.checkPassword, CheckPasswordRequest( password: password, salt: user.salt, hash: user.hash, ), )) { await db.deleteUser(username); return Response.ok(null); } else { return Response.forbidden(); } } else { 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 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]); /// The JWTValidator will automatically use the current time. An empty /// result will mean that the token is valid and its signature was /// verified. if (JWTValidator().validate(jwt, signer: _signer).isEmpty) { final user = await db.getUser(jwt.claims['user']); if (user != null) { request.username = user.name; request.mayUpload = user.mayUpload; request.mayEdit = user.mayEdit; request.mayDelete = user.mayDelete; return request; } else { return Response.unauthorized(); } } else { return Response.unauthorized(); } } else { return Response.badRequest(); } } else { return request; } } }