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 }