server: Better account handling

The account related routes were moved to /account. For performance
reasons, we use a weaker password hash algorithm. The possibility to
modify or delete an account was added.
This commit is contained in:
Elias Projahn 2020-05-13 13:42:07 +02:00
parent 3d3d5d50a6
commit 0847dde610
4 changed files with 144 additions and 45 deletions

View file

@ -8,43 +8,31 @@ import 'compute.dart';
import 'crypt.dart'; import 'crypt.dart';
import 'database.dart'; import 'database.dart';
/// Information on the rights of the user making the request. /// Information on the user making the request.
extension AuthorizationInfo on 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. /// Whether the user may create new resources.
set mayUpload(bool value) => this.attachments['mayUpload'] = value; ///
/// This can only be true if the user was authenticated.
bool get mayUpload => this.attachments['mayUpload'] ?? false; bool get mayUpload => this.attachments['mayUpload'] ?? false;
set mayUpload(bool value) => this.attachments['mayUpload'] = value;
/// Whether the user may edit existing resources. /// Whether the user may edit existing resources.
set mayEdit(bool value) => this.attachments['mayEdit'] = value; ///
/// This can only be true if the user was authenticated.
bool get mayEdit => this.attachments['mayEdit'] ?? false; bool get mayEdit => this.attachments['mayEdit'] ?? false;
set mayEdit(bool value) => this.attachments['mayEdit'] = value;
/// Whether the user may delete resources. /// Whether the user may delete resources.
set mayDelete(bool value) => this.attachments['mayDelete'] = value; ///
/// This can only be true if the user was authenticated.
bool get mayDelete => this.attachments['mayDelete'] ?? false; bool get mayDelete => this.attachments['mayDelete'] ?? false;
} set mayDelete(bool value) => this.attachments['mayDelete'] = value;
/// 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. /// Endpoint controller for user registration.
@ -59,10 +47,13 @@ class RegisterController extends Controller {
Future<Response> handle(Request request) async { Future<Response> handle(Request request) async {
if (request.method == 'POST') { if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>(); final json = await request.body.decode<Map<String, dynamic>>();
final requestUser = RequestUser.fromJson(json);
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. // Check if we already have a user with that name.
final existingUser = await db.getUser(requestUser.name); final existingUser = await db.getUser(username);
if (existingUser != null) { if (existingUser != null) {
// Returning something different than 200 here has the security // Returning something different than 200 here has the security
// implication that an attacker can check for existing user names. At // implication that an attacker can check for existing user names. At
@ -72,11 +63,11 @@ class RegisterController extends Controller {
return Response.conflict(); return Response.conflict();
} else { } else {
// This will take a long time, so we run it in a new isolate. // This will take a long time, so we run it in a new isolate.
final result = await compute(Crypt.hashPassword, requestUser.password); final result = await compute(Crypt.hashPassword, password);
db.updateUser(User( db.updateUser(User(
name: requestUser.name, name: username,
email: requestUser.email, email: email,
salt: result.salt, salt: result.salt,
hash: result.hash, hash: result.hash,
mayUpload: true, mayUpload: true,
@ -109,23 +100,25 @@ class LoginController extends Controller {
Future<Response> handle(Request request) async { Future<Response> handle(Request request) async {
if (request.method == 'POST') { if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>(); final json = await request.body.decode<Map<String, dynamic>>();
final requestUser = RequestUser.fromJson(json);
final realUser = await db.getUser(requestUser.name); final String username = json['username'];
if (realUser != null) { 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 // We check the password in a new isolate, because this can take a long
// time. // time.
if (await compute( if (await compute(
Crypt.checkPassword, Crypt.checkPassword,
CheckPasswordRequest( CheckPasswordRequest(
password: requestUser.password, password: password,
salt: realUser.salt, salt: user.salt,
hash: realUser.hash, hash: user.hash,
), ),
)) { )) {
final builder = JWTBuilder() final builder = JWTBuilder()
..expiresAt = DateTime.now().add(Duration(minutes: 30)) ..expiresAt = DateTime.now().add(Duration(minutes: 30))
..setClaim('user', requestUser.name); ..setClaim('user', username);
final token = builder.getSignedToken(_signer).toString(); final token = builder.getSignedToken(_signer).toString();
@ -140,6 +133,103 @@ class LoginController extends Controller {
} }
} }
/// An endpoint controller for retrieving and changing account details.
class AccountDetailsController extends Controller {
final ServerDatabase db;
AccountDetailsController(this.db);
@override
Future<Response> 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<Map<String, dynamic>>();
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,
),
)) {
final hashResult = await compute(Crypt.hashPassword, newPassword);
db.updateUser(User(
name: username,
email: newEmail,
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<Response> handle(Request request) async {
if (request.method == 'POST') {
final json = await request.body.decode<Map<String, dynamic>>();
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. /// Middleware for checking authorization.
/// ///
/// This will set the fields defined in [AuthorizationInfo] on this request /// This will set the fields defined in [AuthorizationInfo] on this request
@ -172,6 +262,7 @@ class AuthorizationController extends Controller {
if (JWTValidator().validate(jwt, signer: _signer).isEmpty) { if (JWTValidator().validate(jwt, signer: _signer).isEmpty) {
final user = await db.getUser(jwt.claims['user']); final user = await db.getUser(jwt.claims['user']);
if (user != null) { if (user != null) {
request.username = user.name;
request.mayUpload = user.mayUpload; request.mayUpload = user.mayUpload;
request.mayEdit = user.mayEdit; request.mayEdit = user.mayEdit;
request.mayDelete = user.mayDelete; request.mayDelete = user.mayDelete;

View file

@ -38,17 +38,17 @@ class CheckPasswordRequest {
/// Methods for handling passwords. /// Methods for handling passwords.
class Crypt { class Crypt {
static final _crypt = PassCrypt(); static final _crypt = PassCrypt('SHA-512/HMAC/PBKDF2');
static final _rand = Random.secure(); static final _rand = Random.secure();
/// Compute a hash for a password. /// Compute a hash for a password.
/// ///
/// The result will contain the hash and a randomly generated salt. /// The result will contain the hash and a randomly generated salt.
static HashPasswordResult hashPassword(String password) { static HashPasswordResult hashPassword(String password) {
final bytes = List.generate(32, (i) => _rand.nextInt(256)); final bytes = List.generate(32, (i) => _rand.nextInt(256));
final salt = base64UrlEncode(bytes); final salt = base64UrlEncode(bytes);
final hash = _crypt.hashPass(salt, password); final hash = _crypt.hashPass(salt, password);
return HashPasswordResult( return HashPasswordResult(
hash: hash, hash: hash,
salt: salt, salt: salt,

View file

@ -16,4 +16,8 @@ class ServerDatabase extends _$ServerDatabase {
Future<void> updateUser(User user) async { Future<void> updateUser(User user) async {
await into(users).insert(user, mode: InsertMode.insertOrReplace); await into(users).insert(user, mode: InsertMode.insertOrReplace);
} }
Future<void> deleteUser(String name) async {
await (delete(users)..where((u) => u.name.equals(name))).go();
}
} }

View file

@ -41,8 +41,12 @@ class MusicusServer extends ApplicationChannel {
@override @override
Controller get entryPoint => Router() Controller get entryPoint => Router()
..route('/login').link(() => LoginController(serverDb, secret)) ..route('/account/register').link(() => RegisterController(serverDb))
..route('/register').link(() => RegisterController(serverDb)) ..route('/account/details')
.link(() => AuthorizationController(serverDb, secret))
.link(() => AccountDetailsController(serverDb))
..route('/account/delete').link(() => AccountDeleteController(serverDb))
..route('/account/login').link(() => LoginController(serverDb, secret))
..route('/persons/[:id]') ..route('/persons/[:id]')
.link(() => AuthorizationController(serverDb, secret)) .link(() => AuthorizationController(serverDb, secret))
.link(() => PersonsController(db)) .link(() => PersonsController(db))