1 /**
2 	Database abstraction layer
3 
4 	Copyright: © 2012-2015 RejectedSoftware e.K.
5 	License: Subject to the terms of the General Public License version 3, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module userman.db.controller;
9 
10 public import userman.userman;
11 import userman.id;
12 
13 import vibe.data.serialization;
14 import vibe.db.mongo.mongo;
15 import vibe.http.router;
16 import vibe.mail.smtp;
17 import vibe.stream.memory;
18 import vibe.utils.validation;
19 import diet.html;
20 
21 import std.algorithm;
22 import std.array;
23 import std.datetime;
24 import std.exception;
25 import std.random;
26 import std.string;
27 import std.typecons : Nullable;
28 
29 
30 UserManController createUserManController(UserManSettings settings)
31 {
32 	import userman.db.file;
33 	import userman.db.mongo;
34 	import userman.db.redis;
35 
36 	auto url = settings.databaseURL;
37 	if (url.startsWith("redis://")) return new RedisUserManController(settings);
38 	else if (url.startsWith("mongodb://")) return new MongoUserManController(settings);
39 	else if (url.startsWith("file://")) return new FileUserManController(settings);
40 	else throw new Exception("Unknown URL schema: "~url);
41 }
42 
43 class UserManController {
44 @safe:
45 	protected {
46 		UserManSettings m_settings;
47 	}
48 
49 	this(UserManSettings settings)
50 	{
51 		m_settings = settings;
52 	}
53 
54 	@property UserManSettings settings() { return m_settings; }
55 
56 	abstract bool isEmailRegistered(string email);
57 
58 	void validateUser(in ref User usr)
59 	{
60 		enforce(usr.name.length >= 3, "User names must be at least 3 characters long.");
61 		validateEmail(usr.email);
62 	}
63 
64 	abstract User.ID addUser(ref User usr);
65 
66 	User.ID registerUser(string email, string name, string full_name, string password)
67 	{
68 		email = email.toLower();
69 		name = name.toLower();
70 
71 		validateEmail(email);
72 		validatePassword(password, password);
73 
74 		auto need_activation = m_settings.requireActivation;
75 		User user;
76 		user.active = !need_activation;
77 		user.name = name;
78 		user.fullName = full_name;
79 		user.auth.method = "password";
80 		user.auth.passwordHash = generatePasswordHash(password);
81 		assert(validatePasswordHash(user.auth.passwordHash, password));
82 		user.email = email;
83 		if( need_activation )
84 			user.activationCode = generateActivationCode();
85 
86 		addUser(user);
87 
88 		if( need_activation )
89 			resendActivation(email);
90 
91 		return user.id;
92 	}
93 
94 	User.ID inviteUser(string email, string full_name, string message, bool send_mail = true)
95 	{
96 		email = email.toLower();
97 
98 		validateEmail(email);
99 
100 		try {
101 			return getUserByEmail(email).id;
102 		}
103 		catch (Exception e) {
104 			User user;
105 			user.email = email;
106 			user.name = email;
107 			user.fullName = full_name;
108 			addUser(user);
109 
110 			if( m_settings.mailSettings ){
111 				auto msg = appender!string;
112 				auto serviceName = m_settings.serviceName;
113 				auto serviceURL = m_settings.serviceURL;
114 				msg.compileHTMLDietFile!("userman.mail.invitation.dt", user, serviceName, serviceURL);
115 
116 				auto mail = new Mail;
117 				mail.headers["From"] = m_settings.serviceName ~ " <" ~ m_settings.serviceEmail ~ ">";
118 				mail.headers["To"] = email;
119 				mail.headers["Subject"] = "Invitation";
120 				mail.headers["Content-Type"] = "text/html; charset=UTF-8";
121 				mail.bodyText = cast(string)msg.data();
122 
123 				sendMail(m_settings.mailSettings, mail);
124 			}
125 
126 			return user.id;
127 		}
128 	}
129 
130 	Nullable!(User.ID) testLogin(string name, string password)
131 	{
132 		string password_ = password;
133 		auto user = getUserByEmailOrName(name);
134 		assert(password == password_); // this used to be false to to a Nullable related codegen issue
135 		Nullable!(User.ID) ret;
136 		if (validatePasswordHash(user.auth.passwordHash, password_))
137 			ret = user.id;
138 		return ret;
139 	}
140 
141 	void activateUser(string email, string activation_code)
142 	{
143 		email = email.toLower();
144 
145 		auto user = getUserByEmail(email);
146 		enforce(!user.active, "This user account is already activated.");
147 		enforce(user.activationCode == activation_code, "The activation code provided is not valid.");
148 		user.active = true;
149 		user.activationCode = "";
150 		updateUser(user);
151 	}
152 
153 	void resendActivation(string email)
154 	{
155 		email = email.toLower();
156 
157 		auto user = getUserByEmail(email);
158 		enforce(!user.active, "The user account is already active.");
159 
160 		auto msg = appender!string();
161 		auto serviceName = m_settings.serviceName;
162 		auto serviceURL = m_settings.serviceURL;
163 		msg.compileHTMLDietFile!("userman.mail.activation.dt", user, serviceName, serviceURL);
164 
165 		auto mail = new Mail;
166 		mail.headers["From"] = m_settings.serviceName ~ " <" ~ m_settings.serviceEmail ~ ">";
167 		mail.headers["To"] = email;
168 		mail.headers["Subject"] = "Account activation";
169 		mail.headers["Content-Type"] = "text/html; charset=UTF-8";
170 		mail.bodyText = cast(string)msg.data();
171 
172 		sendMail(m_settings.mailSettings, mail);
173 	}
174 
175 	void requestPasswordReset(string email)
176 	{
177 		auto usr = getUserByEmail(email);
178 
179 		string reset_code = generateActivationCode();
180 		SysTime expire_time = Clock.currTime() + dur!"hours"(24);
181 		usr.resetCode = reset_code;
182 		usr.resetCodeExpireTime = expire_time;
183 		updateUser(usr);
184 
185 		if( m_settings.mailSettings ){
186 			auto msg = appender!string();
187 			scope user = () @trusted { return &usr; } ();
188 			auto settings = m_settings;
189 			msg.compileHTMLDietFile!("userman.mail.reset_password.dt", user, reset_code, settings);
190 
191 			auto mail = new Mail;
192 			mail.headers["From"] = m_settings.serviceName ~ " <" ~ m_settings.serviceEmail ~ ">";
193 			mail.headers["To"] = email;
194 			mail.headers["Subject"] = "Account recovery";
195 			mail.headers["Content-Type"] = "text/html; charset=UTF-8";
196 			mail.bodyText = cast(string)msg.data();
197 			sendMail(m_settings.mailSettings, mail);
198 		}
199 	}
200 
201 	void resetPassword(string email, string reset_code, string new_password)
202 	{
203 		validatePassword(new_password, new_password);
204 		auto usr = getUserByEmail(email);
205 		enforce(usr.resetCode.length > 0, "No password reset request was made.");
206 		enforce(Clock.currTime() < usr.resetCodeExpireTime, "Reset code is expired, please request a new one.");
207 		auto code = usr.resetCode;
208 		usr.resetCode = "";
209 		updateUser(usr);
210 		enforce(reset_code == code, "Invalid request code, please request a new one.");
211 		usr.auth.passwordHash = generatePasswordHash(new_password);
212 		updateUser(usr);
213 	}
214 
215 	abstract User getUser(User.ID id);
216 
217 	abstract User getUserByName(string name);
218 
219 	abstract User getUserByEmail(string email);
220 
221 	abstract User getUserByEmailOrName(string email_or_name);
222 
223 	abstract void enumerateUsers(long first_user, long max_count, scope void delegate(ref User usr) @safe del);
224 	final void enumerateUsers(long first_user, long max_count, scope void delegate(ref User usr) del) {
225 		enumerateUsers(first_user, max_count, (ref usr) @trusted { del(usr); });
226 	}
227 
228 	abstract long getUserCount();
229 
230 	abstract void deleteUser(User.ID user_id);
231 
232 	abstract void updateUser(in ref User user);
233 	abstract void setEmail(User.ID user, string email);
234 	abstract void setFullName(User.ID user, string full_name);
235 	abstract void setPassword(User.ID user, string password);
236 	abstract void setProperty(User.ID user, string name, Json value);
237 	abstract void removeProperty(User.ID user, string name);
238 
239 	abstract void addGroup(string id, string description);
240 	abstract void removeGroup(string name);
241 	abstract void setGroupDescription(string name, string description);
242 	abstract long getGroupCount();
243 	abstract Group getGroup(string id);
244 	abstract void enumerateGroups(long first_group, long max_count, scope void delegate(ref Group grp) @safe del);
245 	final void enumerateGroups(long first_group, long max_count, scope void delegate(ref Group grp) del) {
246 		enumerateGroups(first_group, max_count, (ref grp) @trusted { del(grp); });
247 	}
248 	abstract void addGroupMember(string group, User.ID user);
249 	abstract void removeGroupMember(string group, User.ID user);
250 	abstract long getGroupMemberCount(string group);
251 	abstract void enumerateGroupMembers(string group, long first_member, long max_count, scope void delegate(User.ID usr) @safe del);
252 	final void enumerateGroupMembers(string group, long first_member, long max_count, scope void delegate(User.ID usr) del) {
253 		enumerateGroupMembers(group, first_member, max_count, (usr) @trusted { del(usr); });
254 	}
255 	deprecated Group getGroupByName(string id) { return getGroup(id); }
256 
257 	/** Test a group ID for validity.
258 
259 		Valid group IDs consist of one or more dot separated identifiers, where
260 		each idenfifiers must contain only ASCII alphanumeric characters or
261 		underscores. Each identifier must begin with an alphabetic or underscore
262 		character.
263 	*/
264 	static bool isValidGroupID(string name)
265 	{
266 		import std.ascii : isAlpha, isDigit;
267 		import std.algorithm : splitter;
268 
269 		if (name.length < 1) return false;
270 		foreach (p; name.splitter('.')) {
271 			if (p.length < 0) return false;
272 			if (!p[0].isAlpha && p[0] != '_') return false;
273 			if (p.canFind!(ch => !ch.isAlpha && !ch.isDigit && ch != '_'))
274 				return false;
275 		}
276 		return true;
277 	}
278 }
279 
280 struct User {
281 	alias .ID!User ID;
282 	@(.name("_id")) ID id;
283 	bool active;
284 	bool banned;
285 	string name;
286 	string fullName;
287 	string email;
288 	string[] groups;
289 	string activationCode;
290 	string resetCode;
291 	SysTime resetCodeExpireTime;
292 	AuthInfo auth;
293 	Json[string] properties;
294 
295 	bool isInGroup(string group) const { return groups.countUntil(group) >= 0; }
296 }
297 
298 struct AuthInfo {
299 	string method = "password";
300 	string passwordHash;
301 	string token;
302 	string secret;
303 	string info;
304 }
305 
306 struct Group {
307 	string id;
308 	string description;
309 	@optional Json[string] properties;
310 }
311 
312 
313 string generateActivationCode()
314 @safe {
315 	auto ret = appender!string();
316 	foreach( i; 0 .. 10 ){
317 		auto n = cast(char)uniform(0, 62);
318 		if( n < 26 ) ret.put(cast(char)('a'+n));
319 		else if( n < 52 ) ret.put(cast(char)('A'+n-26));
320 		else ret.put(cast(char)('0'+n-52));
321 	}
322 	return ret.data();
323 }
324 
325 string generatePasswordHash(string password)
326 @safe {
327 	import std.base64 : Base64;
328 
329 	// FIXME: use a more secure hash method
330 	ubyte[4] salt;
331 	foreach( i; 0 .. 4 ) salt[i] = cast(ubyte)uniform(0, 256);
332 	ubyte[16] hash = md5hash(salt, password);
333 	return Base64.encode(salt ~ hash).idup;
334 }
335 
336 bool validatePasswordHash(string password_hash, string password)
337 @safe {
338 	import std.base64 : Base64;
339 
340 	// FIXME: use a more secure hash method
341 	import std.string : format;
342 	ubyte[] upass = Base64.decode(password_hash);
343 	enforce(upass.length == 20, format("Invalid binary password hash length: %s", upass.length));
344 	auto salt = upass[0 .. 4];
345 	auto hashcmp = upass[4 .. 20];
346 	ubyte[16] hash = md5hash(salt, password);
347 	return hash == hashcmp;
348 }
349 
350 unittest {
351 	auto h = generatePasswordHash("foobar");
352 	assert(!validatePasswordHash(h, "foo"));
353 	assert(validatePasswordHash(h, "foobar"));
354 }
355 
356 private ubyte[16] md5hash(ubyte[] salt, string[] strs...)
357 @safe {
358 	static if( __traits(compiles, {import std.digest.md;}) ){
359 		import std.digest.md;
360 		MD5 ctx;
361 		ctx.start();
362 		ctx.put(salt);
363 		foreach( s; strs ) ctx.put(cast(const(ubyte)[])s);
364 		return ctx.finish();
365 	} else {
366 		import std.md5;
367 		ubyte[16] hash;
368 		MD5_CTX ctx;
369 		ctx.start();
370 		ctx.update(salt);
371 		foreach( s; strs ) ctx.update(s);
372 		ctx.finish(hash);
373 		return hash;
374 	}
375 }