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 		user.email = email;
82 		if( need_activation )
83 			user.activationCode = generateActivationCode();
84 
85 		addUser(user);
86 
87 		if( need_activation )
88 			resendActivation(email);
89 
90 		return user.id;
91 	}
92 
93 	User.ID inviteUser(string email, string full_name, string message, bool send_mail = true)
94 	{
95 		email = email.toLower();
96 
97 		validateEmail(email);
98 
99 		try {
100 			return getUserByEmail(email).id;
101 		}
102 		catch (Exception e) {
103 			User user;
104 			user.email = email;
105 			user.name = email;
106 			user.fullName = full_name;
107 			addUser(user);
108 
109 			if( m_settings.mailSettings ){
110 				auto msg = appender!string;
111 				auto serviceName = m_settings.serviceName;
112 				auto serviceURL = m_settings.serviceURL;
113 				msg.compileHTMLDietFile!("userman.mail.invitation.dt", user, serviceName, serviceURL);
114 
115 				auto mail = new Mail;
116 				mail.headers["From"] = m_settings.serviceName ~ " <" ~ m_settings.serviceEmail ~ ">";
117 				mail.headers["To"] = email;
118 				mail.headers["Subject"] = "Invitation";
119 				mail.headers["Content-Type"] = "text/html; charset=UTF-8";
120 				mail.bodyText = cast(string)msg.data();
121 
122 				sendMail(m_settings.mailSettings, mail);
123 			}
124 
125 			return user.id;
126 		}
127 	}
128 
129 	Nullable!(User.ID) testLogin(string name, string password)
130 	{
131 		auto user = getUserByEmailOrName(name);
132 		if (validatePasswordHash(user.auth.passwordHash, password))
133 			return Nullable!(User.ID)(user.id);
134 		return Nullable!(User.ID).init;
135 	}
136 
137 	void activateUser(string email, string activation_code)
138 	{
139 		email = email.toLower();
140 
141 		auto user = getUserByEmail(email);
142 		enforce(!user.active, "This user account is already activated.");
143 		enforce(user.activationCode == activation_code, "The activation code provided is not valid.");
144 		user.active = true;
145 		user.activationCode = "";
146 		updateUser(user);
147 	}
148 
149 	void resendActivation(string email)
150 	{
151 		email = email.toLower();
152 
153 		auto user = getUserByEmail(email);
154 		enforce(!user.active, "The user account is already active.");
155 
156 		auto msg = appender!string();
157 		auto serviceName = m_settings.serviceName;
158 		auto serviceURL = m_settings.serviceURL;
159 		msg.compileHTMLDietFile!("userman.mail.activation.dt", user, serviceName, serviceURL);
160 
161 		auto mail = new Mail;
162 		mail.headers["From"] = m_settings.serviceName ~ " <" ~ m_settings.serviceEmail ~ ">";
163 		mail.headers["To"] = email;
164 		mail.headers["Subject"] = "Account activation";
165 		mail.headers["Content-Type"] = "text/html; charset=UTF-8";
166 		mail.bodyText = cast(string)msg.data();
167 
168 		sendMail(m_settings.mailSettings, mail);
169 	}
170 
171 	void requestPasswordReset(string email)
172 	{
173 		auto usr = getUserByEmail(email);
174 
175 		string reset_code = generateActivationCode();
176 		SysTime expire_time = Clock.currTime() + dur!"hours"(24);
177 		usr.resetCode = reset_code;
178 		usr.resetCodeExpireTime = expire_time;
179 		updateUser(usr);
180 
181 		if( m_settings.mailSettings ){
182 			auto msg = appender!string();
183 			scope user = () @trusted { return &usr; } ();
184 			auto settings = m_settings;
185 			msg.compileHTMLDietFile!("userman.mail.reset_password.dt", user, reset_code, settings);
186 
187 			auto mail = new Mail;
188 			mail.headers["From"] = m_settings.serviceName ~ " <" ~ m_settings.serviceEmail ~ ">";
189 			mail.headers["To"] = email;
190 			mail.headers["Subject"] = "Account recovery";
191 			mail.headers["Content-Type"] = "text/html; charset=UTF-8";
192 			mail.bodyText = cast(string)msg.data();
193 			sendMail(m_settings.mailSettings, mail);
194 		}
195 	}
196 
197 	void resetPassword(string email, string reset_code, string new_password)
198 	{
199 		validatePassword(new_password, new_password);
200 		auto usr = getUserByEmail(email);
201 		enforce(usr.resetCode.length > 0, "No password reset request was made.");
202 		enforce(Clock.currTime() < usr.resetCodeExpireTime, "Reset code is expired, please request a new one.");
203 		auto code = usr.resetCode;
204 		usr.resetCode = "";
205 		updateUser(usr);
206 		enforce(reset_code == code, "Invalid request code, please request a new one.");
207 		usr.auth.passwordHash = generatePasswordHash(new_password);
208 		updateUser(usr);
209 	}
210 
211 	abstract User getUser(User.ID id);
212 
213 	abstract User getUserByName(string name);
214 
215 	abstract User getUserByEmail(string email);
216 
217 	abstract User getUserByEmailOrName(string email_or_name);
218 
219 	abstract void enumerateUsers(long first_user, long max_count, scope void delegate(ref User usr) @safe del);
220 	final void enumerateUsers(long first_user, long max_count, scope void delegate(ref User usr) del) {
221 		enumerateUsers(first_user, max_count, (ref usr) @trusted { del(usr); });
222 	}
223 
224 	abstract long getUserCount();
225 
226 	abstract void deleteUser(User.ID user_id);
227 
228 	abstract void updateUser(in ref User user);
229 	abstract void setEmail(User.ID user, string email);
230 	abstract void setFullName(User.ID user, string full_name);
231 	abstract void setPassword(User.ID user, string password);
232 	abstract void setProperty(User.ID user, string name, Json value);
233 	abstract void removeProperty(User.ID user, string name);
234 
235 	abstract void addGroup(string id, string description);
236 	abstract void removeGroup(string name);
237 	abstract void setGroupDescription(string name, string description);
238 	abstract long getGroupCount();
239 	abstract Group getGroup(string id);
240 	abstract void enumerateGroups(long first_group, long max_count, scope void delegate(ref Group grp) @safe del);
241 	final void enumerateGroups(long first_group, long max_count, scope void delegate(ref Group grp) del) {
242 		enumerateGroups(first_group, max_count, (ref grp) @trusted { del(grp); });
243 	}
244 	abstract void addGroupMember(string group, User.ID user);
245 	abstract void removeGroupMember(string group, User.ID user);
246 	abstract long getGroupMemberCount(string group);
247 	abstract void enumerateGroupMembers(string group, long first_member, long max_count, scope void delegate(User.ID usr) @safe del);
248 	final void enumerateGroupMembers(string group, long first_member, long max_count, scope void delegate(User.ID usr) del) {
249 		enumerateGroupMembers(group, first_member, max_count, (usr) @trusted { del(usr); });
250 	}
251 	deprecated Group getGroupByName(string id) { return getGroup(id); }
252 
253 	/** Test a group ID for validity.
254 
255 		Valid group IDs consist of one or more dot separated identifiers, where
256 		each idenfifiers must contain only ASCII alphanumeric characters or
257 		underscores. Each identifier must begin with an alphabetic or underscore
258 		character.
259 	*/
260 	static bool isValidGroupID(string name)
261 	{
262 		import std.ascii : isAlpha, isDigit;
263 		import std.algorithm : splitter;
264 
265 		if (name.length < 1) return false;
266 		foreach (p; name.splitter('.')) {
267 			if (p.length < 0) return false;
268 			if (!p[0].isAlpha && p[0] != '_') return false;
269 			if (p.canFind!(ch => !ch.isAlpha && !ch.isDigit && ch != '_'))
270 				return false;
271 		}
272 		return true;
273 	}
274 }
275 
276 struct User {
277 	alias .ID!User ID;
278 	@(.name("_id")) ID id;
279 	bool active;
280 	bool banned;
281 	string name;
282 	string fullName;
283 	string email;
284 	string[] groups;
285 	string activationCode;
286 	string resetCode;
287 	SysTime resetCodeExpireTime;
288 	AuthInfo auth;
289 	Json[string] properties;
290 
291 	bool isInGroup(string group) const { return groups.countUntil(group) >= 0; }
292 }
293 
294 struct AuthInfo {
295 	string method = "password";
296 	string passwordHash;
297 	string token;
298 	string secret;
299 	string info;
300 }
301 
302 struct Group {
303 	string id;
304 	string description;
305 	@optional Json[string] properties;
306 }
307 
308 
309 string generateActivationCode()
310 @safe {
311 	auto ret = appender!string();
312 	foreach( i; 0 .. 10 ){
313 		auto n = cast(char)uniform(0, 62);
314 		if( n < 26 ) ret.put(cast(char)('a'+n));
315 		else if( n < 52 ) ret.put(cast(char)('A'+n-26));
316 		else ret.put(cast(char)('0'+n-52));
317 	}
318 	return ret.data();
319 }
320 
321 string generatePasswordHash(string password)
322 @safe {
323 	import std.base64 : Base64;
324 
325 	// FIXME: use a more secure hash method
326 	ubyte[4] salt;
327 	foreach( i; 0 .. 4 ) salt[i] = cast(ubyte)uniform(0, 256);
328 	ubyte[16] hash = md5hash(salt, password);
329 	return Base64.encode(salt ~ hash).idup;
330 }
331 
332 bool validatePasswordHash(string password_hash, string password)
333 @safe {
334 	import std.base64 : Base64;
335 
336 	// FIXME: use a more secure hash method
337 	import std.string : format;
338 	ubyte[] upass = Base64.decode(password_hash);
339 	enforce(upass.length == 20, format("Invalid binary password hash length: %s", upass.length));
340 	auto salt = upass[0 .. 4];
341 	auto hashcmp = upass[4 .. 20];
342 	ubyte[16] hash = md5hash(salt, password);
343 	return hash == hashcmp;
344 }
345 
346 private ubyte[16] md5hash(ubyte[] salt, string[] strs...)
347 @safe {
348 	static if( __traits(compiles, {import std.digest.md;}) ){
349 		import std.digest.md;
350 		MD5 ctx;
351 		ctx.start();
352 		ctx.put(salt);
353 		foreach( s; strs ) ctx.put(cast(const(ubyte)[])s);
354 		return ctx.finish();
355 	} else {
356 		import std.md5;
357 		ubyte[16] hash;
358 		MD5_CTX ctx;
359 		ctx.start();
360 		ctx.update(salt);
361 		foreach( s; strs ) ctx.update(s);
362 		ctx.finish(hash);
363 		return hash;
364 	}
365 }