doortts doortts 2017-04-23
signup: Introduce signup email verification
@8c3db373897e1ba89a68f8257255e2e256314453
app/controllers/UserApp.java
--- app/controllers/UserApp.java
+++ app/controllers/UserApp.java
@@ -49,6 +49,7 @@
 import java.util.*;
 
 import static com.feth.play.module.mail.Mailer.getEmailName;
+import static models.NotificationMail.isAllowedEmailDomains;
 import static play.data.Form.form;
 import static play.libs.Json.toJson;
 import static utils.HtmlUtil.defaultSanitize;
@@ -79,6 +80,8 @@
             .getBoolean("application.use.social.login.only", false);
     public static final String FLASH_MESSAGE_KEY = "message";
     public static final String FLASH_ERROR_KEY = "error";
+    private static boolean usingEmailVerification = play.Configuration.root()
+            .getBoolean("application.use.email.verification", false);
 
     @AnonymousCheck
     public static Result users(String query) {
@@ -201,7 +204,7 @@
 
         User sourceUser = User.findByLoginKey(authInfoForm.get().loginIdOrEmail);
 
-        if (isUseSignUpConfirm()) {
+        if (isUsingSignUpConfirm()) {
             if (User.findByLoginId(sourceUser.loginId).state == UserState.LOCKED) {
                 flash(Constants.WARNING, "user.locked");
                 return redirect(getLoginFormURLWithRedirectURL());
@@ -220,6 +223,10 @@
             authenticate = authenticateWithPlainPassword(sourceUser.loginId, authInfoForm.get().password);
         }
 
+        if(authenticate.isLocked()){
+            flash(Constants.WARNING, "user.locked");
+            return logout();
+        }
         if (!authenticate.isAnonymous()) {
             addUserInfoToSession(authenticate);
 
@@ -276,7 +283,7 @@
 
         User sourceUser = User.findByLoginKey(authInfoForm.get().loginIdOrEmail);
 
-        if (isUseSignUpConfirm()) {
+        if (isUsingSignUpConfirm()) {
             if (User.findByLoginId(sourceUser.loginId).state == UserState.LOCKED) {
                 return forbidden(getObjectNodeWithMessage("user.locked"));
             }
@@ -291,6 +298,10 @@
             user =  authenticateWithLdap(authInfoForm.get().loginIdOrEmail, authInfoForm.get().password);
         } else {
             user = authenticateWithPlainPassword(sourceUser.loginId, authInfoForm.get().password);
+        }
+
+        if(user.isLocked()){
+            return forbidden(getObjectNodeWithMessage("user.locked"));
         }
 
         if (!user.isAnonymous()) {
@@ -364,15 +375,27 @@
         validate(newUserForm);
         if (newUserForm.hasErrors()) {
             return badRequest(signup.render("title.signup", newUserForm));
-        } else {
-            User user = createNewUser(newUserForm.get());
-            if (user.state == UserState.LOCKED) {
-                flash(Constants.INFO, "user.signup.requested");
-            } else {
-                addUserInfoToSession(user);
-            }
-            return redirect(routes.Application.index());
         }
+
+        if (!isAllowedEmailDomains(newUserForm.get().email)) {
+            flash(Constants.INFO, "user.unacceptable.email.domain");
+            return badRequest(signup.render("title.signup", newUserForm));
+        }
+
+        User user = createNewUser(newUserForm.get());
+        if (isUsingEmailVerification()) {
+            if (isAllowedEmailDomains(user.email)) {
+                flash(Constants.INFO, "user.verification.mail.sent");
+            } else {
+                flash(Constants.INFO, "user.unacceptable.email.domain");
+            }
+        }
+        if (user.state == UserState.LOCKED && isUsingSignUpConfirm()) {
+            flash(Constants.INFO, "user.signup.requested");
+        } else {
+            addUserInfoToSession(user);
+        }
+        return redirect(routes.Application.index());
     }
 
     private static String newLoginIdWithoutDup(final String candidate, int num) {
@@ -394,9 +417,18 @@
             forceOAuthLogout();
             return User.anonymous;
         }
+        if (!isAllowedEmailDomains(userCredential.email)) {
+            flash(Constants.INFO, "user.unacceptable.email.domain");
+            userCredential.delete();
+            forceOAuthLogout();
+            return User.anonymous;
+        }
         User created = createUserDelegate(userCredential.name, userCredential.email, null);
 
-        if (created.state == UserState.LOCKED) {
+        if(isUsingEmailVerification() && created.isLocked()){
+            flash(Constants.INFO, "user.verification.mail.sent");
+            forceOAuthLogout();
+        } else if (created.state == UserState.LOCKED) {
             flash(Constants.INFO, "user.signup.requested");
             forceOAuthLogout();
         }
@@ -406,7 +438,6 @@
         userCredential.user = created;
         userCredential.update();
 
-        sendMailAboutUserCreationByOAuth(userCredential, created);
         return created;
     }
 
@@ -431,24 +462,81 @@
         return createNewUser(user);
     }
 
-    private static void sendMailAboutUserCreationByOAuth(UserCredential userCredential, User created) {
-        Mail mail = new Mail("New account for Yona", getNewAccountMailBody(created), new String[] { getEmailName(userCredential.name, userCredential.email) });
+    public static Result verifyUser(String loginId, String verificationCode){
+        if(!UserApp.currentUser().isAnonymous()) {
+            return redirect(routes.Application.index());
+        }
+        UserVerification uv = UserVerification.findbyLoginIdAndVerificationCode(loginId, verificationCode);
+        if(uv == null){
+            return notFound("Invalid verification");
+        }
+        if (uv.isValidDate()) {
+            User user = User.findByLoginId(loginId);
+            user.state = UserState.ACTIVE;
+            user.update();
+            uv.invalidate();
+            return ok(verified.render("", loginId));
+        }
+        return notFound("Invalid verification");
+    }
+
+    private static void sendMailAfterUserCreation(User created) {
+        if (!isAllowedEmailDomains(created.email)) {
+            flash(Constants.INFO, "user.unacceptable.email.domain");
+            return;
+        }
+        Mail mail = new Mail(Messages.get("user.verification.signup.confirm")
+                + ": " + getServeIndexPageUrl(),
+                getNewAccountMailBody(created),
+                new String[] { getEmailName(created.email, created.name) });
         Mailer mailer = Mailer.getCustomMailer(Configuration.root().getConfig("play-easymail"));
         mailer.sendMail(mail);
     }
 
     private static Body getNewAccountMailBody(User user){
         String passwordResetUrl = getServeIndexPageUrl() + routes.PasswordResetApp.lostPassword();
-        String html =  "ID: " + user.loginId + "<br/>\n"
-                + "PW: " + user.password + "<br/>\n"
-                + "Email: " + user.email + "<br/>\n<br/>\n<br/>\n"
-                + "Password reset: <a href='" + passwordResetUrl + "' target='_blank'>"
-                + passwordResetUrl + "</a><br/>\n<br/>\n";
-        String text =  "ID: " + user.loginId + "\n"
-                + "PW: " + user.password + "\n"
-                + "Email: " + user.email + "\n\n\n"
-                + "Password reset: " + passwordResetUrl + "\n\n";
-        return new Body(text, html);
+        StringBuilder html = new StringBuilder();
+        StringBuilder plainText = new StringBuilder();
+
+        if(isUsingEmailVerification()){
+            setVerificationMessage(user, html, plainText);
+        }
+        setSignupInfomation(user, passwordResetUrl, html, plainText);
+        return new Body(plainText.toString(), html.toString());
+    }
+
+    private static void setSignupInfomation(User user, String passwordResetUrl, StringBuilder html, StringBuilder plainText) {
+        html.append("URL: <a href='").append(getServeIndexPageUrl()).append("'>")
+                    .append(getServeIndexPageUrl()).append("</a><br/>\n")
+                .append("ID: ").append(user.loginId).append("<br/>\n")
+                .append("Email: ").append(user.email).append("<br/>\n<br/>\n")
+                .append("Password reset: <a href='").append(passwordResetUrl).append("' target='_blank'>")
+                .append(passwordResetUrl).append("</a><br/>\n");
+        plainText.append("URL: ").append(getServeIndexPageUrl()).append("\n")
+                .append("ID: ").append(user.loginId).append("\n")
+                .append("Email: ").append(user.email).append("\n\n")
+                .append("Password reset: ").append(passwordResetUrl).append("\n");
+    }
+
+    private static void setVerificationMessage(User user, StringBuilder html, StringBuilder plainText) {
+        UserVerification verification = UserVerification.findbyUser(user);
+        if(verification == null){
+            verification = UserVerification.newVerification(user);
+        }
+        String verificationUrl = getServeIndexPageUrl()
+                + routes.UserApp.verifyUser(user.loginId, verification.verificationCode).toString();
+        html.append("<h1>").append(Messages.get("user.verification")).append("</h1>\n");
+        html.append("<hr />\n");
+        html.append("<p><a href='").append(verificationUrl).append("'>")
+                .append(Messages.get("user.verification.link.click")).append("</a></p>\n");
+        html.append("<br />\n");
+        html.append("<br />\n");
+
+        plainText.append(Messages.get("user.verification")).append("\n");
+        plainText.append("--------------------------\n");
+        plainText.append(verificationUrl).append("\n");
+        plainText.append("\n");
+        plainText.append("\n");
     }
 
     private static String getServeIndexPageUrl(){
@@ -794,6 +882,10 @@
         }
     }
 
+    private static boolean isUsingEmailVerification() {
+        return usingEmailVerification;
+    }
+
     private enum UserInfoFormTabType {
         PROFILE("profile"),
         PASSWORD("password"),
@@ -1068,7 +1160,7 @@
         }
     }
 
-    public static boolean isUseSignUpConfirm(){
+    public static boolean isUsingSignUpConfirm(){
         Configuration config = play.Play.application().configuration();
         Boolean useSignUpConfirm = config.getBoolean("signup.require.admin.confirm");
         if(useSignUpConfirm == null) {
@@ -1114,17 +1206,24 @@
         RandomNumberGenerator rng = new SecureRandomNumberGenerator();
         user.passwordSalt = rng.nextBytes().toBase64();
         user.password = hashedPassword(user.password, user.passwordSalt);
-        if (isUseSignUpConfirm()) {
+        if (isUsingSignUpConfirm() || isUsingEmailVerification()) {
             user.state = UserState.LOCKED;
         } else {
             user.state = UserState.ACTIVE;
         }
         User.create(user);
         Email.deleteOtherInvalidEmails(user.email);
+        if (isUsingEmailVerification()) {
+            UserVerification.newVerification(user);
+            sendMailAfterUserCreation(user);
+        }
         return user;
     }
 
     public static void addUserInfoToSession(User user) {
+        if(user.isLocked()){
+            return;
+        }
         String key = new Sha256Hash(new Date().toString(), ByteSource.Util.bytes(user.passwordSalt), 1024)
                 .toBase64();
         CacheStore.yonaUsers.put(user.id, user);
app/models/NotificationMail.java
--- app/models/NotificationMail.java
+++ app/models/NotificationMail.java
@@ -435,7 +435,7 @@
 
         if(StringUtils.isNotBlank(Application.ALLOWED_SENDING_MAIL_DOMAINS)){
             for(String domain: Application.ALLOWED_SENDING_MAIL_DOMAINS.split(",")){
-                acceptableDomains.add(StringUtils.defaultString(domain, "").trim());
+                acceptableDomains.add(StringUtils.defaultString(domain, "").toLowerCase().trim());
             }
         }
 
@@ -451,6 +451,24 @@
         return filteredUsers;
     }
 
+    public static boolean isAllowedEmailDomains(String email) {
+        List<String> acceptableDomains = new ArrayList<>();
+
+        if(StringUtils.isBlank(Application.ALLOWED_SENDING_MAIL_DOMAINS)){
+            return true;
+        } else {
+            for (String domain : Application.ALLOWED_SENDING_MAIL_DOMAINS.split(",")) {
+                acceptableDomains.add(StringUtils.defaultString(domain, "").toLowerCase().trim());
+            }
+        }
+
+        String domain = getDomainFromEmail(email);
+        if(domain == null || !acceptableDomains.contains(domain.toLowerCase())) {
+            return false;
+        }
+        return true;
+    }
+
     private static int getPartialRecipientSize(Set<User> receivers) {
         if (recipientLimit == RECIPIENT_NO_LIMIT) {
             return receivers.size();
app/models/User.java
--- app/models/User.java
+++ app/models/User.java
@@ -973,4 +973,8 @@
         list.addAll(projects);
         return list;
     }
+
+    public boolean isLocked() {
+        return this.state == UserState.LOCKED || this.state == UserState.DELETED;
+    }
 }
app/models/UserCredential.java
--- app/models/UserCredential.java
+++ app/models/UserCredential.java
@@ -142,4 +142,18 @@
     public static List<UserCredential> findByUserId(Long id){
         return find.where().eq("user.id", id).findList();
     }
+
+    @Override
+    public String toString() {
+        return "UserCredential{" +
+                "id=" + id +
+                ", user=" + user +
+                ", loginId='" + loginId + '\'' +
+                ", email='" + email + '\'' +
+                ", name='" + name + '\'' +
+                ", active=" + active +
+                ", emailValidated=" + emailValidated +
+                ", linkedAccounts=" + linkedAccounts +
+                '}';
+    }
 }
 
app/models/UserVerification.java (added)
+++ app/models/UserVerification.java
@@ -0,0 +1,77 @@
+/**
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
+package models;
+
+import play.db.ebean.Model;
+
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.OneToOne;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+@Entity
+public class UserVerification extends Model {
+    private static final long serialVersionUID = 7819377239127603471L;
+
+    public static final Model.Finder<Long, UserVerification> find = new Finder<>(Long.class, UserVerification.class);
+
+    @Id
+    public Long id;
+
+    @OneToOne
+    public User user;
+
+    public String loginId;
+
+    public String verificationCode;
+
+    public Long timestamp;
+
+    public static UserVerification newVerification(User user) {
+        UserVerification v = new UserVerification();
+        v.user = user;
+        v.loginId = user.loginId;
+        v.verificationCode = UUID.randomUUID().toString();
+        v.timestamp = new Date().getTime();
+        v.save();
+        return v;
+    }
+
+    public static UserVerification findbyUser(User user) {
+        List<UserVerification> list = find.where().eq("user.id", user.id).findList();
+        if (list != null && list.size() > 0) {
+            return list.get(0);
+        } else {
+            return null;
+        }
+    }
+
+    public static UserVerification findbyLoginIdAndVerificationCode(String loginId, String verificationCode) {
+        List<UserVerification> list = find.where().eq("login_id", loginId)
+                .eq("verificationCode", verificationCode).findList();
+        if (list != null && list.size() > 0) {
+            return list.get(0);
+        } else {
+            return null;
+        }
+    }
+
+    public boolean isValidDate(){
+        if( this.timestamp + 60*60*24  > new Date().getTime()) {
+            return true;
+        } else {
+            this.delete();
+            return false;
+        }
+    }
+
+    public void invalidate(){
+        this.delete();
+    }
+}
app/views/user/signup.scala.html
--- app/views/user/signup.scala.html
+++ app/views/user/signup.scala.html
@@ -31,7 +31,7 @@
     <p class="tag-line">@Messages("app.description")</p>
   </div>
 
-  @if(UserApp.isUseSignUpConfirm){
+  @if(UserApp.isUsingSignUpConfirm){
     <div class="center-txt">
       <p>@Messages("title.signupConfirmDesc")</p>
       <p>@Html(Messages("title.signupConfirmDesc2", new StringBuilder(User.findByLoginId(SiteAdmin.SITEADMIN_DEFAULT_LOGINID).email).reverse().toString()))</p>
 
app/views/user/verified.scala.html (added)
+++ app/views/user/verified.scala.html
@@ -0,0 +1,20 @@
+@**
+* Yona, 21st Century Project Hosting SW
+*
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
+**@
+@(message: String, loginId:String)
+
+@siteLayout(message, utils.MenuType.NONE) {
+<div class="page full">
+  <div class="center-wrap tag-line-wrap reset-password">
+    <h1 class="title">
+      @Html(Messages("user.verified"))
+    </h1>
+    <p>@loginId</p>
+    <hr />
+    <p class="tag-line">@Messages("user.verified.detail")</p>
+  </div>
+</div>
+}
conf/application.conf.default
--- conf/application.conf.default
+++ conf/application.conf.default
@@ -21,9 +21,40 @@
 # want to allow that, set signup.require.confirm to true.
 application.allowsAnonymousAccess=true
 
+#
+# Signup options
+# ~~~~~~~~~~~~~~
+
 # If you wants to make the user available to use yona
 # after the server administrator approved,uncomment below
+#
 # signup.require.admin.confirm = true
+
+# If you only want to allow for signing up in specific email domains,
+# use the following option.
+# application.allowed.sending.mail.domains = "gmail.com, your-company.com"
+# And "" is option for no restriction.
+#
+# application.allowed.sending.mail.domains = ""
+
+# If following email verification option is true, all user will be locked when it sign-up,
+# until user click the verification link of verification confirm mail
+#
+# application.use.email.verification = true
+
+# If you enable to use social login or email verification, set followings
+play-easymail {
+  from {
+    # Mailing from address
+    email="projects.yona@gmail.com"
+
+    # Mailing name
+    name="yona-no-reply"
+
+    # Seconds between sending mail through Akka (defaults to 1)
+    # delay=1
+  }
+}
 
 # Notification
 # ~~~~~
@@ -270,6 +301,8 @@
 # 2,147,483,454 bytes = 2Gb
 application.maxFileSize = 2147483454
 
+
+
 # Social Login Support
 # ~~~~~~~~~~~~~~~~~~~~
 # Social login settings for Yona
@@ -298,20 +331,6 @@
     baseDN = "ou=scientists,dc=example,dc=com"
     # If your ldap service's distinguishedName is 'CN=username,OU=user,DC=abc,DC=com', postfix is 'OU=xxx,DC=abc,DC=com'
     distinguishedNamePostfix = "OU=user,DC=abc,DC=com"
-}
-
-# If you enable to use social login, set followings
-play-easymail {
-  from {
-    # Mailing from address
-    email="your@mail-adress.com"
-
-    # Mailing name
-    name="Yona Admin"
-
-    # Seconds between sending mail through Akka (defaults to 1)
-    # delay=1
-  }
 }
 
 include "social-login.conf"
 
conf/evolutions/default/15.sql (added)
+++ conf/evolutions/default/15.sql
@@ -0,0 +1,17 @@
+# --- !Ups
+CREATE TABLE user_verification (
+  id                        BIGINT AUTO_INCREMENT NOT NULL,
+  user_id                   BIGINT,
+  login_id                  VARCHAR(255),
+  verification_code         VARCHAR(255),
+  timestamp                 BIGINT,
+  CONSTRAINT pk_user_verification PRIMARY KEY (id),
+  CONSTRAINT fk_user_verification_user FOREIGN KEY (user_id) REFERENCES n4user (id) on DELETE CASCADE
+)
+row_format=compressed, key_block_size=8;
+
+CREATE index ix_user_verification_user_1 ON user_verification (user_id);
+CREATE index ix_user_verification_user_2 ON user_verification (login_id, verification_code);
+
+# --- !Downs
+DROP TABLE user_verification;
conf/messages
--- conf/messages
+++ conf/messages
@@ -988,6 +988,13 @@
 user.signup.requested = Sign-up request has been sent. Site admin will review and accept your request. Thanks.
 user.signupBtn = Sign up
 user.signupId = User ID (lower case)
+user.unacceptable.email.domain = Your email domain doesn't allowed.
+user.verification.mail.sent = User verification mail was sent.
+user.verification = User verification
+user.verification.signup.confirm = New Sign-up Confirm
+user.verification.link.click = Click to verify
+user.verified = Verified User
+user.verified.detail = User is verified. Try login.
 user.wrongEmail.alert = Wrong email address.
 user.wrongPassword.alert = Wrong password!
 user.wrongloginId.alert = Enter Valid ID
conf/messages.ko-KR
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
@@ -982,6 +982,13 @@
 user.signup.requested = 가입 요청을 보냈습니다. 사이트 관리자가 검토/승인 후 사용가능합니다. 감사합니다.
 user.signupBtn = 참여하기
 user.signupId = 아이디
+user.verification.mail.sent = 사용자 정보 확인을 위한 메일을 보냈습니다
+user.verification = 사용자 정보 확인
+user.verification.signup.confirm = 새 가입 확인
+user.verification.link.click = 유효 이메일 확인을 위해 클릭하세요
+user.verified = 확인된 사용자
+user.verified.detail = 사용자 정보가 확인되었습니다. 다시 로그인 해주세요
+user.unacceptable.email.domain = 가입이 허용되지 않은 이메일 도메인 입니다.
 user.wrongEmail.alert = 이메일이 잘못되었습니다.
 user.wrongPassword.alert = 잘못된 비밀번호입니다!
 user.wrongloginId.alert = 올바른 아이디를 입력하세요.
conf/routes
--- conf/routes
+++ conf/routes
@@ -127,6 +127,7 @@
 POST           /lostPassword                                                          controllers.PasswordResetApp.requestResetPasswordEmail()
 GET            /resetPassword                                                         controllers.PasswordResetApp.resetPasswordForm(s:String)
 POST           /resetPassword                                                         controllers.PasswordResetApp.resetPassword()
+GET            /verify/:loginId/:verificationCode                                     controllers.UserApp.verifyUser(loginId:String, verificationCode:String)
 GET            /sites/postList                                                        controllers.SiteApp.postList(pageNum: Int ?= 1)
 GET            /sites/issueList                                                       controllers.SiteApp.issueList(pageNum: Int ?= 1)
 
Add a comment
List