made admin interface work with mysql

This commit is contained in:
Johannes Loher 2017-11-09 02:24:15 +01:00
commit 1e6b119ebd
13 changed files with 413 additions and 55 deletions

View file

@ -4,7 +4,8 @@ USE CalendarWebapp;
CREATE TABLE users ( CREATE TABLE users (
id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT, id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT,
username CHAR(30) NOT NULL UNIQUE, username CHAR(30) NOT NULL UNIQUE,
password CHAR(60) NOT NULL, passwordHash CHAR(60) NOT NULL,
privilege TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
@ -19,5 +20,5 @@ CREATE TABLE events (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
INSERT INTO users (username, password) VALUES ('foo', INSERT INTO users (username, passwordHash, privilege) VALUES ('foo',
'$2a$10$9LBqOZV99ARiE4Nx.2b7GeYfqk2.0A32PWGu2cRGyW2hRJ0xeDfnO'); '$2a$10$9LBqOZV99ARiE4Nx.2b7GeYfqk2.0A32PWGu2cRGyW2hRJ0xeDfnO', 2);

View file

@ -1,12 +1,23 @@
module calendarwebapp.authenticator; module calendarwebapp.authenticator;
import calendarwebapp.passhash : PasswordHasher;
import poodinis; import poodinis;
import std.conv : to;
import std.range : InputRange;
import std.typecons : nullable, Nullable;
import vibe.data.bson;
import vibe.db.mongo.collection : MongoCollection; import vibe.db.mongo.collection : MongoCollection;
interface Authenticator interface Authenticator
{ {
bool checkUser(string username, string password) @safe; Nullable!AuthInfo checkUser(string username, string password) @safe;
void addUser(AuthInfo authInfo);
InputRange!AuthInfo getAllUsers();
void removeUser(string id);
} }
class MongoDBAuthenticator(Collection = MongoCollection) : Authenticator class MongoDBAuthenticator(Collection = MongoCollection) : Authenticator
@ -14,20 +25,64 @@ class MongoDBAuthenticator(Collection = MongoCollection) : Authenticator
private: private:
@Value("users") @Value("users")
Collection users; Collection users;
@Autowire PasswordHasher passwordHasher;
public: public:
bool checkUser(string username, string password) @safe Nullable!AuthInfo checkUser(string username, string password) @safe
{ {
import botan.passhash.bcrypt : checkBcrypt; import botan.passhash.bcrypt : checkBcrypt;
import vibe.data.bson : Bson;
auto result = users.findOne(["username" : username]); auto result = users.findOne(["username" : username]);
/* checkBcrypt should be called using vibe.core.concurrency.async to /* checkHash should be called using vibe.core.concurrency.async to
avoid blocking, but https://github.com/vibe-d/vibe.d/issues/1521 is avoid blocking, but https://github.com/vibe-d/vibe.d/issues/1521 is
blocking this */ blocking this */
return (result != Bson(null)) && (() @trusted => checkBcrypt(password, if (result != Bson(null))
result["password"].get!string))(); {
auto authInfo = result.deserializeBson!AuthInfo;
if (passwordHasher.checkHash(password, authInfo.passwordHash))
{
return authInfo.nullable;
} }
}
return Nullable!AuthInfo.init;
}
void addUser(AuthInfo authInfo) @safe
{
import std.conv : ConvException;
try
{
if (!BsonObjectID.fromString(authInfo.id).valid)
throw new ConvException("invalid BsonObjectID.");
}
catch (ConvException)
{
authInfo.id = BsonObjectID.generate.to!string;
}
users.insert(authInfo.serializeToBson);
}
InputRange!AuthInfo getAllUsers() @safe
{
import std.algorithm : map;
import std.range : inputRangeObject;
return users.find().map!(deserializeBson!AuthInfo).inputRangeObject;
}
void removeUser(string id) @safe
{
users.remove(["_id" : id]);
}
}
enum Privilege
{
None,
User,
Admin
} }
class MySQLAuthenticator : Authenticator class MySQLAuthenticator : Authenticator
@ -36,27 +91,108 @@ private:
import mysql; import mysql;
@Autowire MySQLPool pool; @Autowire MySQLPool pool;
@Autowire PasswordHasher passwordHasher;
public: public:
bool checkUser(string username, string password) @trusted Nullable!AuthInfo checkUser(string username, string password) @trusted
{ {
import botan.passhash.bcrypt : checkBcrypt;
auto cn = pool.lockConnection(); auto cn = pool.lockConnection();
scope (exit) scope (exit)
cn.close(); cn.close();
auto prepared = cn.prepare("SELECT password FROM users WHERE username = ?"); auto prepared = cn.prepare(
"SELECT id, username, passwordHash, privilege FROM users WHERE username = ?");
prepared.setArg(0, username); prepared.setArg(0, username);
auto result = prepared.query(); auto result = prepared.query();
/* checkHash should be called using vibe.core.concurrency.async to
/* checkBcrypt should be called using vibe.core.concurrency.async to
avoid blocking, but https://github.com/vibe-d/vibe.d/issues/1521 is avoid blocking, but https://github.com/vibe-d/vibe.d/issues/1521 is
blocking this */ blocking this */
return !result.empty && (() @trusted => checkBcrypt(password, result.front[0].get!string))(); if (!result.empty)
{
auto authInfo = toAuthInfo(result.front);
if (passwordHasher.checkHash(password, authInfo.passwordHash))
{
return authInfo.nullable;
}
}
return Nullable!AuthInfo.init;
}
void addUser(AuthInfo authInfo)
{
auto cn = pool.lockConnection();
scope (exit)
cn.close;
auto prepared = cn.prepare(
"INSERT INTO users (username, passwordHash, privilege) VALUES(?, ?, ?)");
prepared.setArgs(authInfo.username, authInfo.passwordHash, authInfo.privilege.to!uint);
prepared.exec();
}
InputRange!AuthInfo getAllUsers()
{
import std.algorithm : map;
import std.range : inputRangeObject;
auto cn = pool.lockConnection();
scope (exit)
cn.close;
auto prepared = cn.prepare("SELECT id, username, passwordHash, privilege FROM users");
return prepared.querySet.map!(r => toAuthInfo(r)).inputRangeObject;
}
void removeUser(string id)
{
auto cn = pool.lockConnection();
scope (exit)
cn.close;
auto prepared = cn.prepare("DELETE FROM users WHERE id = ?");
prepared.setArg(0, id.to!uint);
prepared.exec();
}
private:
AuthInfo toAuthInfo(Row r)
{
import std.conv : to;
AuthInfo authInfo;
authInfo.id = r[0].get!uint.to!string;
authInfo.username = r[1].get!string;
authInfo.passwordHash = r[2].get!string;
authInfo.privilege = r[3].get!uint.to!Privilege;
return authInfo;
} }
} }
struct AuthInfo struct AuthInfo
{ {
string userName; import vibe.data.serialization : name;
@name("_id") string id;
string username;
string passwordHash;
Privilege privilege;
mixin(generateAuthMethods);
private:
static string generateAuthMethods() pure @safe
{
import std.conv : to;
import std.format : format;
import std.traits : EnumMembers;
string ret;
foreach (member; EnumMembers!Privilege)
{
ret ~= q{
bool is%s() const pure @safe nothrow
{
return privilege == Privilege.%s;
}
}.format(member.to!string, member.to!string);
}
return ret;
}
} }

View file

@ -1,7 +1,8 @@
module calendarwebapp.calendarwebapp; module calendarwebapp.calendarwebapp;
import calendarwebapp.authenticator : Authenticator, AuthInfo; import calendarwebapp.authenticator;
import calendarwebapp.event; import calendarwebapp.event;
import calendarwebapp.passhash : PasswordHasher;
import core.time : days; import core.time : days;
@ -23,46 +24,49 @@ import vibe.web.web : errorDisplay, noRoute, redirect, render, SessionVar,
{ {
@noRoute AuthInfo authenticate(scope HTTPServerRequest req, scope HTTPServerResponse) @safe @noRoute AuthInfo authenticate(scope HTTPServerRequest req, scope HTTPServerResponse) @safe
{ {
if (!req.session || !req.session.isKeySet("auth")) if (authInfo.value.isNone)
{
redirect("/login"); redirect("/login");
return AuthInfo.init;
} return authInfo.value;
return req.session.get!AuthInfo("auth");
} }
public: public:
@anyAuth void index() @auth(Role.user | Role.admin) void index()
{ {
auto events = eventStore.getAllEvents(); auto events = eventStore.getAllEvents();
render!("showevents.dt", events); auto authInfo = this.authInfo.value;
render!("showevents.dt", events, authInfo);
} }
@noAuth void getLogin(string _error = null) @noAuth void getLogin(string _error = null)
{ {
render!("login.dt", _error); auto authInfo = this.authInfo.value;
render!("login.dt", _error, authInfo);
} }
@noAuth @errorDisplay!getLogin void postLogin(string username, string password) @safe @noAuth @errorDisplay!getLogin void postLogin(string username, string password) @safe
{ {
enforce(authenticator.checkUser(username, password), "Benutzername oder Passwort ungültig"); auto authInfo = authenticator.checkUser(username, password);
immutable AuthInfo authInfo = {username}; enforce(!authInfo.isNull, "Benutzername oder Passwort ungültig");
auth = authInfo; this.authInfo = authInfo.get;
redirect("/"); redirect("/");
} }
@anyAuth void getLogout() @safe @auth(Role.user | Role.admin) void getLogout() @safe
{ {
terminateSession(); terminateSession();
redirect("/"); redirect("/");
} }
@anyAuth void getCreate(ValidationErrorData _error = ValidationErrorData.init) @auth(Role.user | Role.admin) void getCreateevent(
ValidationErrorData _error = ValidationErrorData.init)
{ {
render!("create.dt", _error); auto authInfo = this.authInfo.value;
render!("createevent.dt", _error, authInfo);
} }
@anyAuth @errorDisplay!getCreate void postCreate(Date begin,
@auth(Role.user | Role.admin) @errorDisplay!getCreateevent void postCreateevent(Date begin,
Nullable!Date end, string description, string name, EventType type, bool shout) Nullable!Date end, string description, string name, EventType type, bool shout)
{ {
import std.array : replace, split; import std.array : replace, split;
@ -77,12 +81,39 @@ public:
redirect("/"); redirect("/");
} }
@anyAuth void postRemove(string id) @auth(Role.user | Role.admin) void postRemoveevent(string id)
{ {
eventStore.removeEvent(id); eventStore.removeEvent(id);
redirect("/"); redirect("/");
} }
@auth(Role.admin) void getUsers()
{
auto users = authenticator.getAllUsers;
auto authInfo = this.authInfo.value;
render!("showusers.dt", users, authInfo);
}
@auth(Role.admin) void postRemoveuser(string id)
{
authenticator.removeUser(id);
redirect("/users");
}
@auth(Role.admin) void getCreateuser(ValidationErrorData _error = ValidationErrorData.init)
{
auto authInfo = this.authInfo.value;
render!("createuser.dt", _error, authInfo);
}
@auth(Role.admin) @errorDisplay!getCreateuser void postCreateuser(string username,
string password, Privilege role)
{
authenticator.addUser(AuthInfo("", username,
passwordHasher.generateHash(password), role));
redirect("/users");
}
private: private:
struct ValidationErrorData struct ValidationErrorData
{ {
@ -90,8 +121,10 @@ private:
string field; string field;
} }
SessionVar!(AuthInfo, "auth") auth; SessionVar!(AuthInfo, "authInfo") authInfo = AuthInfo("",
string.init, string.init, Privilege.None);
@Autowire EventStore eventStore; @Autowire EventStore eventStore;
@Autowire Authenticator authenticator; @Autowire Authenticator authenticator;
@Autowire PasswordHasher passwordHasher;
} }

View file

@ -1,8 +1,13 @@
module calendarwebapp.configuration; module calendarwebapp.configuration;
import calendarwebapp.authenticator : Authenticator, MongoDBAuthenticator, MySQLAuthenticator; import botan.rng.auto_rng : AutoSeededRNG;
import botan.rng.rng : RandomNumberGenerator;
import calendarwebapp.authenticator : Authenticator, MongoDBAuthenticator,
MySQLAuthenticator;
import calendarwebapp.calendarwebapp : CalendarWebapp; import calendarwebapp.calendarwebapp : CalendarWebapp;
import calendarwebapp.event : EventStore, MongoDBEventStore, MySQLEventStore; import calendarwebapp.event : EventStore, MongoDBEventStore, MySQLEventStore;
import calendarwebapp.passhash : BcryptPasswordHasher, PasswordHasher;
import mysql : MySQLPool; import mysql : MySQLPool;
@ -18,11 +23,14 @@ public:
override void registerDependencies(shared(DependencyContainer) container) override void registerDependencies(shared(DependencyContainer) container)
{ {
auto mongoClient = connectMongoDB("localhost"); auto mongoClient = connectMongoDB("localhost");
auto pool = new MySQLPool("localhost", "username", "password", "CalendarWebapp"); auto pool = new MySQLPool("localhost", "root", "Ilemm3Kzj", "CalendarWebapp");
container.register!MySQLPool.existingInstance(pool); container.register!MySQLPool.existingInstance(pool);
container.register!MongoClient.existingInstance(mongoClient); container.register!MongoClient.existingInstance(mongoClient);
container.register!(EventStore, MySQLEventStore); container.register!(EventStore, MySQLEventStore);
container.register!(Authenticator, MySQLAuthenticator); container.register!(Authenticator, MySQLAuthenticator);
container.register!(PasswordHasher, BcryptPasswordHasher);
container.register!(RandomNumberGenerator, AutoSeededRNG);
container.register!CalendarWebapp; container.register!CalendarWebapp;
container.register!(ValueInjector!string, StringInjector); container.register!(ValueInjector!string, StringInjector);
container.register!(ValueInjector!MongoCollection, MongoCollectionInjector); container.register!(ValueInjector!MongoCollection, MongoCollectionInjector);

View file

@ -129,8 +129,6 @@ private:
Event toEvent(Row r) Event toEvent(Row r)
{ {
import std.conv : to;
Event event; Event event;
event.id = r[0].get!uint.to!string; event.id = r[0].get!uint.to!string;
event.begin = r[1].get!Date; event.begin = r[1].get!Date;

View file

@ -0,0 +1,42 @@
module calendarwebapp.passhash;
import poodinis;
interface PasswordHasher
{
string generateHash(in string password) @safe;
bool checkHash(in string password, in string hash) const @safe;
}
class BcryptPasswordHasher : PasswordHasher
{
import botan.passhash.bcrypt : checkBcrypt, generateBcrypt;
import botan.rng.rng : RandomNumberGenerator;
string generateHash(in string password) @safe
{
return (() @trusted => generateBcrypt(password, rng, cost))();
}
bool checkHash(in string password, in string hash) const @safe
{
return (()@trusted => checkBcrypt(password, hash))();
}
private:
@Autowire RandomNumberGenerator rng;
enum cost = 10;
}
class StubPasswordHasher : PasswordHasher
{
string generateHash(in string password) const @safe pure nothrow
{
return password;
}
bool checkHash(in string password, in string hash) const @safe pure nothrow
{
return password == hash;
}
}

View file

@ -1,6 +1,7 @@
module test.calendarwebapp.testauthenticator; module test.calendarwebapp.testauthenticator;
import calendarwebapp.authenticator; import calendarwebapp.authenticator;
import calendarwebapp.passhash : PasswordHasher, StubPasswordHasher;
import poodinis; import poodinis;
@ -11,7 +12,10 @@ import vibe.data.bson : Bson, BsonObjectID;
interface Collection interface Collection
{ {
Bson[] find() @safe;
Bson findOne(string[string] query) @safe; Bson findOne(string[string] query) @safe;
void insert(Bson document) @safe;
void remove(string[string] selector) @safe;
} }
class CollectionInjector : ValueInjector!Collection class CollectionInjector : ValueInjector!Collection
@ -31,7 +35,7 @@ public:
} }
} }
@("Test MongoDBAuthenticator") @("MongoDBAuthenticator.checkUser")
@system unittest @system unittest
{ {
auto collection = mock!Collection; auto collection = mock!Collection;
@ -40,14 +44,63 @@ public:
container.resolve!CollectionInjector.add("users", collection); container.resolve!CollectionInjector.add("users", collection);
container.register!(Authenticator, MongoDBAuthenticator!(Collection))( container.register!(Authenticator, MongoDBAuthenticator!(Collection))(
RegistrationOption.doNotAddConcreteTypeRegistration); RegistrationOption.doNotAddConcreteTypeRegistration);
container.register!(PasswordHasher, StubPasswordHasher);
auto userBson = Bson(["_id" : Bson(BsonObjectID.fromString("5988ef4ae6c19089a1a53b79")), "username" : Bson("foo"), auto userBson = Bson(["_id" : Bson("5988ef4ae6c19089a1a53b79"), "username"
"password" : Bson("$2a$10$9LBqOZV99ARiE4Nx.2b7GeYfqk2.0A32PWGu2cRGyW2hRJ0xeDfnO")]); : Bson("foo"), "passwordHash" : Bson("bar"), "privilege" : Bson(1)]);
collection.returnValue!"findOne"(Bson(null), userBson, userBson); collection.returnValue!"findOne"(Bson(null), userBson, userBson);
auto authenticator = container.resolve!(Authenticator); auto authenticator = container.resolve!(Authenticator);
authenticator.checkUser("", "").shouldBeFalse; authenticator.checkUser("", "").isNull.shouldBeTrue;
authenticator.checkUser("foo", "bar").shouldBeTrue; authenticator.checkUser("foo", "bar").isNull.shouldBeFalse;
authenticator.checkUser("foo", "baz").shouldBeFalse; authenticator.checkUser("foo", "baz").isNull.shouldBeTrue;
}
@("AuthInfo.isUser success")
@safe unittest
{
AuthInfo auth;
auth.privilege = Privilege.User;
auth.isUser.shouldBeTrue;
}
@("AuthInfo.isUser failure")
@safe unittest
{
AuthInfo auth;
auth.privilege = Privilege.None;
auth.isUser.shouldBeFalse;
}
@("AuthInfo.isAdmin success")
@safe unittest
{
AuthInfo auth;
auth.privilege = Privilege.Admin;
auth.isAdmin.shouldBeTrue;
}
@("AuthInfo.isAdmin failure")
@safe unittest
{
AuthInfo auth;
auth.privilege = Privilege.None;
auth.isAdmin.shouldBeFalse;
}
@("AuthInfo.isNone success")
@safe unittest
{
AuthInfo auth;
auth.privilege = Privilege.None;
auth.isNone.shouldBeTrue;
}
@("AuthInfo.isNone failure")
@safe unittest
{
AuthInfo auth;
auth.privilege = Privilege.User;
auth.isNone.shouldBeFalse;
} }

View file

@ -0,0 +1,31 @@
module test.calendarwebapp.testpasshash;
import calendarwebapp.passhash;
import poodinis;
import unit_threaded;
@("BcryptPasswordHasher")
@Values("", "test", "langesKompliziertesPasswort")
@system unittest
{
import botan.rng.rng : RandomNumberGenerator;
import botan.rng.auto_rng : AutoSeededRNG;
auto container = new shared DependencyContainer;
container.register!(RandomNumberGenerator, AutoSeededRNG);
container.register!(PasswordHasher, BcryptPasswordHasher);
auto hasher = container.resolve!PasswordHasher;
immutable testPassword = getValue!string;
hasher.checkHash(testPassword, hasher.generateHash(testPassword)).shouldBeTrue;
}
@("StubPasswordHasher")
@Values("", "test", "langesKompliziertesPasswort")
@safe unittest
{
immutable hasher = new StubPasswordHasher;
immutable testPassword = getValue!string;
hasher.checkHash(testPassword, hasher.generateHash(testPassword)).shouldBeTrue;
}

View file

@ -3,7 +3,7 @@ block content
- void showerror(string field = null) - void showerror(string field = null)
- if (_error.msg && _error.field == field) - if (_error.msg && _error.field == field)
td.error= _error.msg td.error= _error.msg
form(action="/create", method="post") form(action="/createevent", method="post")
fieldset(name="eventFields") fieldset(name="eventFields")
table table
tbody#fieldTable tbody#fieldTable

32
views/createuser.dt Normal file
View file

@ -0,0 +1,32 @@
extends layout
block content
- void showerror(string field = null)
- if (_error.msg && _error.field == field)
td.error= _error.msg
form(action="/createuser", method="post")
fieldset(name="eventFields")
table
tbody#fieldTable
tr
td
label(for="username") Benutzername
td
input#username(value="", name="username", type="text")
- showerror("username");
tr
td
label(for="password") Passwort
td
input#password(value="", name="password", type="password")
tr
td
label(for="role") Rolle
td
select#type(name="role")
option(value="User") Benutzer
option(value="Admin") Administrator
- showerror("role");
tfoot
tr
td(colspan="2")
input#submitButton(type="submit", value="Benutzer erstellen")

View file

@ -1,8 +1,14 @@
nav - if(!authInfo.isNone())
nav
ul ul
li li
a(href='/') Home a(href='/') Home
li li
a(href='/create') Ereignis erstellen a(href='/createevent') Ereignis erstellen
- if(authInfo.isAdmin())
li
a(href='/users') Benutzerliste
li
a(href='/createuser') Benutzer erstellen
li li
a(href='/logout') Ausloggen a(href='/logout') Ausloggen

View file

@ -24,7 +24,7 @@ block content
tr tr
td shout td shout
td #{event.shout} td #{event.shout}
form(action="/remove", method="post") form(action="/removeevent", method="post")
input#id(value="#{event.id}", name="id", type="hidden") input#id(value="#{event.id}", name="id", type="hidden")
input#submitButton(type="submit", value="Entfernen") input#submitButton(type="submit", value="Entfernen")
hr hr

18
views/showusers.dt Normal file
View file

@ -0,0 +1,18 @@
extends layout.dt
block content
h1 Users
- foreach (user; users)
table
tr
td id
td #{user.id}
tr
td username
td #{user.username}
tr
td privilege
td #{user.privilege}
form(action="/removeuser", method="post")
input#id(value="#{user.id}", name="id", type="hidden")
input#submitButton(type="submit", value="Entfernen")
hr