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 }