001/* 002 * Stallion Core: A Modern Web Framework 003 * 004 * Copyright (C) 2015 - 2016 Stallion Software LLC. 005 * 006 * This program is free software: you can redistribute it and/or modify it under the terms of the 007 * GNU General Public License as published by the Free Software Foundation, either version 2 of 008 * the License, or (at your option) any later version. This program is distributed in the hope that 009 * it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 010 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public 011 * License for more details. You should have received a copy of the GNU General Public License 012 * along with this program. If not, see <http://www.gnu.org/licenses/gpl-2.0.html>. 013 * 014 * 015 * 016 */ 017 018package io.stallion.users; 019 020import io.stallion.Context; 021import io.stallion.dataAccess.AuditTrailEnabled; 022import io.stallion.dataAccess.DataAccessRegistration; 023import io.stallion.dataAccess.DataAccessRegistry; 024import io.stallion.dataAccess.StandardModelController; 025import io.stallion.dataAccess.db.DB; 026import io.stallion.dataAccess.db.DbPersister; 027import io.stallion.dataAccess.file.JsonFilePersister; 028import io.stallion.email.ContactableEmailer; 029import io.stallion.exceptions.ClientException; 030import io.stallion.requests.StRequest; 031import io.stallion.services.LocalMemoryCache; 032import io.stallion.services.Log; 033import io.stallion.settings.Settings; 034import io.stallion.utils.DateUtils; 035import io.stallion.utils.Encrypter; 036import io.stallion.utils.GeneralUtils; 037import io.stallion.utils.json.JSON; 038import org.apache.commons.lang3.RandomStringUtils; 039import org.apache.commons.lang3.StringUtils; 040import org.mindrot.jbcrypt.BCrypt; 041 042import javax.servlet.http.Cookie; 043import java.io.File; 044import java.lang.annotation.Retention; 045import java.lang.annotation.RetentionPolicy; 046import java.net.URLEncoder; 047import java.util.Map; 048import java.util.UUID; 049 050import static io.stallion.utils.Literals.*; 051import static io.stallion.Context.*; 052 053@AuditTrailEnabled 054public class UserController<T extends IUser> extends StandardModelController<T> { 055 private static final String PROBLEM_LOG_CACHE_BUCKET = "problemLog"; 056 private static final int PROBLEM_LOG_DURATION_SECONDS = 5 * 60; 057 private static final int MAX_PROBLEMS = 7; 058 059 public static String USER_COOKIE_NAME = "stUserSession"; 060 061 062 public static <Y extends IUser> UserController<Y> instance() { 063 return (UserController<Y>) DataAccessRegistry.instance().get("users"); 064 } 065 066 public static void load() { 067 DataAccessRegistration registration = new DataAccessRegistration() 068 .setStashClass(UserMemoryStash.class) 069 .setControllerClass(UserController.class) 070 .setModelClass(User.class); 071 if (DB.instance() == null) { 072 073 registration 074 .setPersisterClass(JsonFilePersister.class) 075 .setPath("users") 076 .setNameSpace("") 077 .setWritable(true) 078 .setUseDataFolder(true) 079 .setShouldWatch(true); 080 081 registration.hydratePaths(Settings.instance().getTargetFolder()); 082 File usersDir = new File(registration.getAbsolutePath()); 083 if (!usersDir.isDirectory()) { 084 usersDir.mkdirs(); 085 } 086 } else { 087 registration 088 .setTableName("stallion_users") 089 .setBucket("users") 090 .setPersisterClass(DbPersister.class); 091 if (!Settings.instance().getUsers().getSyncAllUsersToMemory()) { 092 registration.setStashClass(UserPartialStash.class); 093 } 094 095 } 096 Context.dal().register(registration); 097 098 } 099 100 101 public T forEmail(String email) { 102 // TODO make lookup by key work 103 T user = filter("email", email).first(); 104 if (user == null) { 105 return null; 106 } 107 if (!empty(user.getAliasForId())) { 108 user = forId(user.getAliasForId()); 109 } 110 return user; 111 } 112 113 public T forUsername(String username) { 114 // TODO make lookup by key work 115 T user = filter("username", username).first(); 116 if (user != null && !empty(user.getAliasForId())) { 117 user = forId(user.getAliasForId()); 118 } 119 return user; 120 } 121 122 public T createUser(T user) { 123 save(user); 124 return user; 125 } 126 127 /** 128 * Hydrates the bycryptedPassword field, validiating the password for minimum length and matching confirmation. 129 * 130 * @param user 131 * @param password 132 * @param passwordConfirm 133 */ 134 public void hydratePassword(T user, String password, String passwordConfirm) { 135 136 if (empty(password) || password.length() < 6) { 137 throw new ClientException("Password is empty or too short"); 138 } 139 140 if (!password.equals(passwordConfirm)) { 141 throw new ClientException("Confirmation password does not match!"); 142 } 143 144 String hashed = BCrypt.hashpw(password, BCrypt.gensalt()); 145 user.setBcryptedPassword(hashed); 146 } 147 148 149 @Override 150 public void onPreCreatePrepare(T user) { 151 user.setIsNewInsert(true); 152 if (empty(user.getSecret())) { 153 user.setSecret(RandomStringUtils.randomAlphanumeric(18)); 154 } 155 if (empty(user.getEncryptionSecret())) { 156 user.setEncryptionSecret(RandomStringUtils.randomAlphanumeric(36)); 157 } 158 if (user.getCreatedAt() == null || user.getCreatedAt() == 0) { 159 user.setCreatedAt(DateUtils.mils()); 160 } 161 162 163 } 164 165 /** 166 * Checks the standard Stallion auth cookie, loads and validates the user, 167 * and hydrates the current request Context user, and returns true. Returns 168 * false if there is not cookie, or it did not represent a valid user. 169 * 170 * @param request 171 * @return 172 */ 173 public boolean checkCookieAndAuthorizeForRequest(StRequest request) { 174 Cookie userCookie = request.getCookie(UserController.USER_COOKIE_NAME); 175 if (userCookie == null) { 176 return false; 177 } 178 try { 179 UserValetResult result = UserController.instance().cookieStringToUser(userCookie.getValue()); 180 if (result != null && result.getUser() != null) { 181 Context.setUser(result.getUser()); 182 if (result.getValet() != null) { 183 Context.setValet(result.getValet().getId(), result.getValet().getEmail()); 184 } 185 return true; 186 } else { 187 return false; 188 } 189 190 } catch (Exception e) { 191 Log.exception(e, "Error loading user from cookie"); 192 return false; 193 } 194 } 195 196 /** 197 * Void out the authentication cookie 198 */ 199 public void logoff() { 200 Context.getResponse().addCookie(UserController.USER_COOKIE_NAME, "", 1); 201 } 202 203 /** 204 * Checks the user information represents a valid login, adds the user to the context, 205 * and adds a cookie to the current request response. 206 * 207 * @param username 208 * @param password 209 * @param rememberMe 210 * @return 211 */ 212 public T loginUser(String username, String password, Boolean rememberMe) { 213 T user = checkUserLoginValid(username, password); 214 return addSessionCookieForUser(user, rememberMe); 215 } 216 217 218 219 public T valetLoginIfAllowed(String email) { 220 T user = forEmail(email); 221 if (user == null) { 222 user = forUsername(email); 223 } 224 if (user == null) { 225 throw new ClientException("Could not find user matching email " + email); 226 } 227 return valetLoginIfAllowed(user); 228 } 229 230 public T valetLoginIfAllowed(Long userId) { 231 T user = forIdOrNotFound(userId); 232 return valetLoginIfAllowed(user); 233 } 234 235 public T valetLoginIfAllowed(T user) { 236 if (!Settings.instance().getUsers().getAllowValetMode()) { 237 throw new ClientException("Valet mode not enabled for this site."); 238 } 239 T valet = (T)Context.getUser(); 240 if (valet == null) { 241 throw new ClientException("You are not logged in, cannot use valet mode"); 242 } 243 244 Long valetId = null; 245 if (!empty(Context.getValetUserId())) { 246 valet = forId(Context.getValetUserId()); 247 } else if (!valet.isInRole(Role.ADMIN)) { 248 throw new ClientException("You must be an admin to use valet mode"); 249 } 250 if (valet.getId().equals(user.getId())) { 251 // Valet is switching out of valet mode, back to being their normal user 252 return addSessionCookieForUser(user, true); 253 } 254 if (valet.getRole().getValue() <= user.getRole().getValue()) { 255 throw new ClientException("You cannot valet to a user who has the same role as you, only into a user of lesser role."); 256 } 257 return addSessionCookieForUser(user, false, valet); 258 } 259 260 261 public void logoutCurrentUser() { 262 263 } 264 265 /** 266 * Returns a user if the login information is valid, throws a ClientException exception otherwise. 267 * 268 * @param username 269 * @param password 270 * @return 271 */ 272 public T checkUserLoginValid(String username, String password) throws ClientException { 273 Integer failures = or((Integer)LocalMemoryCache.get(PROBLEM_LOG_CACHE_BUCKET, request().getActualIp()), 0); 274 if (failures > MAX_PROBLEMS) { 275 throw new ClientException("You have too many login failures in the last 10 minutes. Please wait before trying again.", 429); 276 } 277 278 279 T user = forUsername(username); 280 String err = "User not found or password invalid"; 281 if (user == null) { 282 markFailed(username); 283 throw new ClientException(err, 403); 284 } 285 286 failures = or((Integer)LocalMemoryCache.get(PROBLEM_LOG_CACHE_BUCKET, username), 0); 287 if (failures > (MAX_PROBLEMS + 5)) { // We are more tolerant of user name failures, else easy to lock someone else out of account 288 throw new ClientException("You have too many login failures in the last 10 minutes. Please wait before trying again.", 429); 289 } 290 291 if (empty(user.getBcryptedPassword())) { 292 throw new ClientException("Password never confiruged for this user. Did you originally login with Google or Facebook? Otherwise, click on the password reset link to choose a new password."); 293 } 294 295 boolean valid = BCrypt.checkpw(password, user.getBcryptedPassword()); 296 297 298 299 if (!valid) { 300 markFailed(username); 301 throw new ClientException(err, 403); 302 } 303 return user; 304 } 305 306 /** 307 * Mark a login failure in the local cache, too many failures and the user or IP address will be locked out. 308 * 309 * @param username 310 */ 311 public void markFailed(String username) { 312 Integer failures = or((Integer)LocalMemoryCache.get(PROBLEM_LOG_CACHE_BUCKET, request().getActualIp()), 0); 313 Log.fine("Mark login failed {0} {1} failCount={2}", username, request().getActualIp(), failures + 1); 314 LocalMemoryCache.set(PROBLEM_LOG_CACHE_BUCKET, request().getActualIp(), failures + 1, PROBLEM_LOG_DURATION_SECONDS); 315 316 if (!empty(username)) { 317 failures = or((Integer) LocalMemoryCache.get(PROBLEM_LOG_CACHE_BUCKET, username), 0); 318 LocalMemoryCache.set(PROBLEM_LOG_CACHE_BUCKET, username, failures + 1, PROBLEM_LOG_DURATION_SECONDS); 319 } 320 321 } 322 323 /** 324 * Add a session cookie to the current request response for this user. 325 * 326 * @param user 327 * @param rememberMe 328 * @return 329 */ 330 public T addSessionCookieForUser(T user, Boolean rememberMe) { 331 return addSessionCookieForUser(user, rememberMe, null); 332 } 333 /** 334 * Add a session cookie to the current request response for this user. 335 * 336 * @param user 337 * @param rememberMe 338 * @return 339 */ 340 public T addSessionCookieForUser(T user, Boolean rememberMe, T valetUser) { 341 Long valetUserId = null; 342 if (valetUser != null) { 343 valetUserId = valetUser.getId(); 344 } 345 String cookie = userToCookieString(user, rememberMe, valetUserId); 346 int expires = (int)((mils() + (86400*30*1000))/1000); 347 if (rememberMe) { 348 Context.getResponse().addCookie(UserController.USER_COOKIE_NAME, cookie, expires); 349 } else { 350 Context.getResponse().addCookie(UserController.USER_COOKIE_NAME, cookie); 351 } 352 return user; 353 } 354 355 /** 356 * Change the primary email of this user to an already validated and verified 357 * that is alaread an alias for the user id. Returns false if invalid for any reseason. 358 * 359 * @param user 360 * @param newPrimaryEmail 361 * @return 362 */ 363 public boolean changePrimaryEmail(T user, String newPrimaryEmail) { 364 T aliasUser = forUniqueKey("email", newPrimaryEmail); 365 if (aliasUser == null) { 366 return false; 367 } 368 if (!aliasUser.getEmailVerified()) { 369 return false; 370 } 371 if (!aliasUser.getAliasForId().equals(user.getId())) { 372 return false; 373 } 374 boolean orgWasVerified = user.getEmailVerified(); 375 String orgEmail = user.getEmail(); 376 if (user.getUsername().equals(orgEmail)) { 377 user.setUsername(newPrimaryEmail); 378 } 379 user.setEmail(newPrimaryEmail); 380 user.setEmailVerified(true); 381 382 // Need to set a placeholder to avoid unique key errors 383 String placeholder = UUID.randomUUID().toString() + "@" + UUID.randomUUID().toString() + ".com"; 384 aliasUser.setEmail(placeholder); 385 aliasUser.setUsername(placeholder); 386 save(aliasUser); 387 388 // Now save the user 389 save(user); 390 391 // Now put the old primary 392 aliasUser.setUsername(orgEmail); 393 aliasUser.setEmail(orgEmail); 394 aliasUser.setEmailVerified(orgWasVerified); 395 save(aliasUser); 396 397 return true; 398 } 399 400 public boolean sendEmailVerifyEmail(String email) { 401 return sendEmailVerifyEmail(email, ""); 402 } 403 404 public boolean sendEmailVerifyEmail(String email, String returnUrl) { 405 T user = forUniqueKey("email", email); 406 return sendEmailVerifyEmail(user, returnUrl); 407 } 408 409 public boolean sendEmailVerifyEmail(T user) { 410 return sendEmailVerifyEmail(user, ""); 411 } 412 public boolean sendEmailVerifyEmail(T user, String returnUrl) { 413 414 if (user == null) { 415 return false; 416 } 417 if (user.isPredefined()) { 418 throw new ClientException("You cannot verify email for a builtin user. You must edit this user in your configuration files."); 419 } 420 // send email 421 String token = makeVerifyEmailToken(user); 422 new VerifyEmailEmailer(user, token, returnUrl).sendEmail(); 423 return true; 424 } 425 426 public String makeVerifyEmailToken(T user) { 427 if (empty(user.getResetToken())) { 428 user.setResetToken(GeneralUtils.randomToken(14)); 429 save(user); 430 } 431 return makeEncryptedToken(user, "verifyEmail", user.getResetToken()); 432 } 433 434 public String makeEncryptedToken(T user, String type, String value) { 435 String fullToken = user.getId() + "|" + type + "|" + DateUtils.mils() + "|" + value; 436 String encryptedToken = Encrypter.encryptString(user.getEncryptionSecret(), fullToken); 437 return encryptedToken; 438 } 439 440 public String readEncryptedToken(T user, String expectedType, String encrypted, int expiresMinutes) { 441 String full = Encrypter.decryptString(user.getEncryptionSecret(), encrypted); 442 String[] parts = full.split("\\|", 4); 443 Log.info("decrypted token {0}", full); 444 long actualId = Long.parseLong(parts[0]); 445 long userId = user.getId(); 446 String actualType = parts[1]; 447 long createdAt = Long.parseLong(parts[2]); 448 String value = parts[3]; 449 if (actualId != userId) { 450 Log.finer("Token has userId: {0} but passed in user had id: {1}", actualId, userId); 451 return null; 452 } 453 if (!actualType.equals(expectedType)) { 454 Log.finer("Incorrect token type expected:{0} got: {1}", expectedType, actualType); 455 return null; 456 } 457 if ((createdAt + (expiresMinutes * 60 * 1000)) < DateUtils.mils()) { 458 Log.finer("Token has expired"); 459 return null; 460 } 461 return value; 462 } 463 464 public boolean verifyEmailVerifyToken(String email, String encryptedToken) { 465 T user = forUniqueKey("email", email); 466 return verifyEmailVerifyToken(user, encryptedToken); 467 } 468 469 public boolean verifyEmailVerifyToken(T user, String encryptedToken) { 470 if (user == null) { 471 return false; 472 } 473 String resetToken = readEncryptedToken(user, "verifyEmail", encryptedToken, 10*60*24); 474 if (empty(resetToken)) { 475 return false; 476 } 477 if (resetToken.equals(user.getResetToken())) { 478 return true; 479 } 480 481 return false; 482 } 483 484 public void markEmailVerified(String email, String token) { 485 markEmailVerified(forUniqueKeyOrNotFound("email", email), token); 486 } 487 488 public void markEmailVerified(T user, String token) { 489 if (!verifyEmailVerifyToken(user, token)) { 490 throw new ClientException("Invalid verification token"); 491 } 492 user.setEmailVerified(true); 493 user.setResetToken(""); 494 // set reset token to a new value 495 496 if (Settings.instance().getUsers().getNewAccountsAutoApprove()) { 497 user.setApproved(true); 498 } 499 save(user); 500 } 501 502 503 public boolean sendPasswordResetEmail(String email) { 504 return sendPasswordResetEmail(forEmail(email), ""); 505 } 506 507 public boolean sendPasswordResetEmail(String email, String returnUrl) { 508 return sendPasswordResetEmail(forEmail(email), returnUrl); 509 } 510 511 public boolean sendPasswordResetEmail(T user, String returnUrl) { 512 if (user == null) { 513 return false; 514 } 515 if (user.isPredefined()) { 516 throw new ClientException("You cannot reset the password for a builtin user. You must edit this user in your configuration files."); 517 } 518 if (empty(user.getResetToken())) { 519 user.setResetToken(GeneralUtils.randomToken(14)); 520 save(user); 521 } 522 523 524 525 String encryptedToken = makeEncryptedToken(user, "reset", user.getResetToken()); 526 527 ResetEmailEmailer emailer = new ResetEmailEmailer(user, encryptedToken, returnUrl); 528 emailer.sendEmail(); 529 return true; 530 } 531 532 public boolean verifyPasswordResetToken(String email, String encryptedToken) { 533 return verifyPasswordResetToken(forEmail(email), encryptedToken); 534 } 535 536 public boolean verifyPasswordResetToken(T user, String encryptedToken) { 537 if (user == null) { 538 return false; 539 } 540 String token = readEncryptedToken(user, "reset", encryptedToken, 10*60*24); 541 if (empty(token)) { 542 return false; 543 } 544 if (!token.equals(user.getResetToken())) { 545 return true; 546 } 547 return true; 548 } 549 550 public T changePassword(String email, String token, String newPassword, String confirmNewPassword) { 551 return changePassword(forEmail(email), token, newPassword, confirmNewPassword); 552 } 553 554 public T changePassword(T user, String token, String newPassword, String confirmNewPassword) { 555 if (!verifyPasswordResetToken(user, token)) { 556 throw new ClientException("Invalid reset token"); 557 } 558 hydratePassword(user, newPassword, confirmNewPassword); 559 user.setResetToken(""); 560 this.save(user); 561 return user; 562 } 563 564 565 public String userToCookieString(T user, Boolean rememberMe) { 566 return userToCookieString(user, rememberMe, null); 567 } 568 569 public String userToCookieString(T user, Boolean rememberMe, Long valetId) { 570 Long now = mils(); 571 Long expires = now + (86400L * 1000L); 572 if (rememberMe) { 573 expires = now + (90L * 86400L * 1000L); 574 } 575 SessionInfo session = new SessionInfo() 576 .setSec(user.getSecret()) 577 .setCrt(mils()) 578 .setExp(expires); 579 if (!empty(valetId)) { 580 session.setVid(valetId); 581 } 582 583 String encryptedPart = Encrypter.encryptString(user.getEncryptionSecret(), JSON.stringify(session)); 584 585 String cookie = user.getId().toString() + "&" + encryptedPart; 586 return cookie; 587 } 588 589 public UserValetResult cookieStringToUser(String cookie) { 590 String[] parts = cookie.split("&", 2); 591 if (parts.length < 2) { 592 return null; 593 } 594 if (!StringUtils.isNumeric(parts[0])) { 595 Log.warn("Invalid user id found in cookie: {0}", parts[0]); 596 return null; 597 } 598 Long id = Long.parseLong(parts[0]); 599 String encryptedJson = parts[1]; 600 601 602 T user = forId(id); 603 if (user == null) { 604 return null; 605 } 606 String json = Encrypter.decryptString(user.getEncryptionSecret(), encryptedJson); 607 SessionInfo info = JSON.parse(json, SessionInfo.class); 608 609 if (info.getExp() < mils()) { 610 return null; 611 } 612 if (!info.getSec().equals(user.getSecret())) { 613 return null; 614 } 615 UserValetResult result = new UserValetResult() 616 .setUser(user); 617 if (!empty(info.getVid())) { 618 T valet = forId(info.getVid()); 619 if (valet == null) { 620 return null; 621 } 622 result.setValet(valet); 623 } 624 return result; 625 626 } 627 628 public static class UserValetResult { 629 public IUser user; 630 public IUser valet; 631 632 public IUser getUser() { 633 return user; 634 } 635 636 public UserValetResult setUser(IUser user) { 637 this.user = user; 638 return this; 639 } 640 641 public IUser getValet() { 642 return valet; 643 } 644 645 public UserValetResult setValet(IUser valet) { 646 this.valet = valet; 647 return this; 648 } 649 } 650 651 public static class SessionInfo { 652 private String sec = ""; 653 private Long exp = 0L; 654 private Long crt = 0L; 655 private Long vid = 0L; 656 657 public String getSec() { 658 return sec; 659 } 660 661 public SessionInfo setSec(String sec) { 662 this.sec = sec; 663 return this; 664 } 665 666 667 public Long getExp() { 668 return exp; 669 } 670 671 public SessionInfo setExp(Long exc) { 672 this.exp = exc; 673 return this; 674 } 675 676 public Long getCrt() { 677 return crt; 678 } 679 680 public SessionInfo setCrt(Long crt) { 681 this.crt = crt; 682 return this; 683 } 684 685 public Long getVid() { 686 return vid; 687 } 688 689 public SessionInfo setVid(Long vid) { 690 this.vid = vid; 691 return this; 692 } 693 } 694 695 public class VerifyEmailEmailer extends ContactableEmailer { 696 697 public VerifyEmailEmailer(T user, Map<String, Object> context) { 698 super(user, context); 699 } 700 701 public VerifyEmailEmailer(T user, String token, String returnUrl) { 702 super(user); 703 put("verifyToken", token); 704 String url = Settings.instance().getUsers().getVerifyEmailPage(); 705 if (!url.contains("//")) { 706 url = Settings.instance().getSiteUrl() + url; 707 } 708 709 try { 710 url = url + "?verifyToken=" + URLEncoder.encode(token, "UTF-8") + 711 "&email=" + URLEncoder.encode(user.getEmail(), "UTF-8"); 712 if (!empty(returnUrl)) { 713 url += "&returnUrl=" + URLEncoder.encode(returnUrl, "UTF-8"); 714 } 715 } catch (Exception e) { 716 throw new RuntimeException(e); 717 } 718 url = url + "#verify-email"; 719 put("verifyUrl", url); 720 } 721 722 @Override 723 public boolean isTransactional() { 724 return true; 725 } 726 727 @Override 728 public String getTemplate() { 729 return "stallion:email/verify-email-address.jinja"; 730 } 731 732 @Override 733 public String getSubject() { 734 return "Verify your email address."; 735 } 736 737 public String getUniqueKey() { 738 return truncate(GeneralUtils.slugify(getSubject()), 150) + "-" + user.getEmail() + "-" + minuteStamp + getEmailType(); 739 } 740 } 741 742 public class ResetEmailEmailer extends ContactableEmailer { 743 744 public ResetEmailEmailer(T user, String resetToken, String returnUrl) { 745 super(user); 746 put("resetToken", resetToken); 747 String url = Settings.instance().getUsers().getPasswordResetPage(); 748 if (!url.contains("//")) { 749 url = Settings.instance().getSiteUrl() + url; 750 } 751 try { 752 url = url + "?resetToken=" + URLEncoder.encode(resetToken, "UTF-8") + 753 "&email=" + URLEncoder.encode(user.getEmail(), "UTF-8") + 754 "&returnUrl=" + URLEncoder.encode(or(returnUrl, ""), "UTF-8"); 755 } catch (Exception e) { 756 throw new RuntimeException(e); 757 } 758 url = url + "#confirm-reset-token"; 759 put("resetUrl", url); 760 } 761 762 @Override 763 public boolean isTransactional() { 764 return true; 765 } 766 767 @Override 768 public String getTemplate() { 769 return "stallion:email/reset-password.jinja"; 770 } 771 772 @Override 773 public String getSubject() { 774 return "Reset your password for " + Settings.instance().getSiteName(); 775 } 776 777 778 public String getUniqueKey() { 779 return truncate(GeneralUtils.slugify(getSubject()), 150) + "-" + user.getEmail() + "-" + minuteStamp + getEmailType(); 780 } 781 782 } 783}