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}