001package org.deepamehta.plugins.signup;
002
003import com.sun.jersey.api.view.Viewable;
004import de.deepamehta.core.Association;
005import de.deepamehta.core.ChildTopics;
006import de.deepamehta.core.Topic;
007import de.deepamehta.core.model.*;
008import de.deepamehta.core.service.DeepaMehtaEvent;
009import de.deepamehta.core.service.DeepaMehtaService;
010import de.deepamehta.core.service.EventListener;
011import de.deepamehta.core.service.Inject;
012import de.deepamehta.core.service.accesscontrol.AccessControl;
013import de.deepamehta.core.service.accesscontrol.Credentials;
014import de.deepamehta.core.service.event.PostUpdateTopicListener;
015import de.deepamehta.core.storage.spi.DeepaMehtaTransaction;
016import de.deepamehta.plugins.accesscontrol.AccessControlService;
017import de.deepamehta.plugins.webactivator.WebActivatorPlugin;
018import de.deepamehta.plugins.workspaces.WorkspacesService;
019import java.net.MalformedURLException;
020import java.net.URI;
021import java.net.URISyntaxException;
022import java.net.URL;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.Date;
026import java.util.HashMap;
027import java.util.UUID;
028import java.util.concurrent.Callable;
029import java.util.logging.Level;
030import java.util.logging.Logger;
031import javax.mail.internet.InternetAddress;
032import javax.ws.rs.*;
033import javax.ws.rs.core.Context;
034import javax.ws.rs.core.MediaType;
035import javax.ws.rs.core.UriInfo;
036import javax.ws.rs.WebApplicationException;
037import javax.ws.rs.core.Response;
038
039import org.apache.commons.mail.HtmlEmail;
040import org.codehaus.jettison.json.JSONException;
041import org.codehaus.jettison.json.JSONObject;
042import org.deepamehta.plugins.signup.service.SignupPluginService;
043
044/**
045 * This plugin enables anonymous users to create themselves a user account in DeepaMehta 4
046 * through an Email based confirmation workflow and thus it critically depends on a postfix
047 * like "internet" setup on "localhost".
048 *
049 * Routes registerd by this plugin are:
050 * "/sign-up"                           signup-form (registration dialog)
051 * "/sign-up/login"                     login-form (frontpage, alternate logind dialog)
052 * "/sign-up/ok"                        info-page after sucessfull account creation
053 * "/sign-up/error"                     info-page after failure (no account creation)
054 * "/sign-up/token-info"        info-page notifying about confirmation mail
055 *
056 * @name dm4-sign-up
057 * @website https://github.com/mukil/dm4-sign-up
058 * @version 1.1-SNAPSHOT
059 * @author <a href="mailto:malte@mikromedia.de">Malte Reissig</a>;
060 */
061@Path("/sign-up")
062public class SignupPlugin extends WebActivatorPlugin implements SignupPluginService, PostUpdateTopicListener {
063
064    private static Logger log = Logger.getLogger(SignupPlugin.class.getName());
065
066    // --- DeepaMehta 4 related URIs --- //
067    public static final String MAILBOX_TYPE_URI = "dm4.contacts.email_address";
068    public static final String DM4_HOST_URL = System.getProperty("dm4.host.url");
069    public static final boolean DM4_ACCOUNTS_ENABLED = Boolean.parseBoolean(System.getProperty("dm4.security" +
070            ".new_accounts_are_enabled"));
071    public static final String CONFIG_TOPIC_ACCOUNT_ENABLED = "dm4.accesscontrol.login_enabled";
072
073    // --- Sign-up related type URIs (Configuration, Template Data) --- //
074    private final String SIGN_UP_PLUGIN_TOPIC_URI = "org.deepamehta.sign-up";
075    private final String SIGN_UP_CONFIG_TYPE_URI = "org.deepamehta.signup.configuration";
076    private final String CONFIG_PROJECT_TITLE = "org.deepamehta.signup.config_project_title";
077    private final String CONFIG_WEBAPP_TITLE = "org.deepamehta.signup.config_webapp_title";
078    private final String CONFIG_LOGO_PATH = "org.deepamehta.signup.config_webapp_logo_path";
079    private final String CONFIG_CSS_PATH = "org.deepamehta.signup.config_custom_css_path";
080    private final String CONFIG_READ_MORE_URL = "org.deepamehta.signup.config_read_more_url";
081    private final String CONFIG_PAGES_FOOTER = "org.deepamehta.signup.config_pages_footer";
082    private final String CONFIG_TOS_LABEL = "org.deepamehta.signup.config_tos_label";
083    private final String CONFIG_TOS_DETAILS = "org.deepamehta.signup.config_tos_detail";
084    private final String CONFIG_PD_LABEL = "org.deepamehta.signup.config_pd_label";
085    private final String CONFIG_PD_DETAILS = "org.deepamehta.signup.config_pd_detail";
086    private final String CONFIG_FROM_MAILBOX = "org.deepamehta.signup.config_from_mailbox";
087    private final String CONFIG_ADMIN_MAILBOX = "org.deepamehta.signup.config_admin_mailbox";
088    private final String CONFIG_EMAIL_CONFIRMATION = "org.deepamehta.signup.config_email_confirmation";
089
090    private Topic currentModuleConfiguration = null;
091
092    @Inject
093    private AccessControlService acService;
094
095    @Inject
096    private WorkspacesService wsService;
097
098    @Context
099    UriInfo uri;
100
101    HashMap<String, JSONObject> token = new HashMap<String, JSONObject>();
102
103    @Override
104    public void init() {
105        initTemplateEngine();
106        reloadConfiguration();
107    }
108
109    /**
110     * Custom event fired by sign-up module up on successful user account creation.
111     *
112     * @return Topic    The username topic (related to newly created user account
113     * topic).
114     */
115    static DeepaMehtaEvent USER_ACCOUNT_CREATE_LISTENER = new DeepaMehtaEvent(UserAccountCreateListener.class) {
116        @Override
117        public void deliver(EventListener listener, Object... params) {
118            ((UserAccountCreateListener) listener).userAccountCreated((Topic) params[0]);
119        }
120    };
121
122
123
124    // --- Plugin Service Implementation --- //
125
126    @GET
127    @Path("/check/{username}")
128    @Produces(MediaType.APPLICATION_JSON)
129    public String getUsernameAvailability(@PathParam("username") String username) {
130        JSONObject response = new JSONObject();
131        try {
132            response.put("isAvailable", true);
133            if (isUsernameTaken(username)) {
134                response.put("isAvailable", false);
135            }
136            return response.toString();
137        } catch (Exception e) {
138            throw new RuntimeException(e);
139        }
140    }
141
142    @GET
143    @Path("/check/mailbox/{email}")
144    @Produces(MediaType.APPLICATION_JSON)
145    public String getMailboxAvailability(@PathParam("email") String email) {
146        JSONObject response = new JSONObject();
147        try {
148            response.put("isAvailable", true);
149            if (isMailboxTaken(email)) {
150                response.put("isAvailable", false);
151            }
152            return response.toString();
153        } catch (Exception e) {
154            throw new RuntimeException(e);
155        }
156    }
157
158    @GET
159    @Path("/handle/{username}/{pass-one}/{mailbox}")
160    public Viewable handleSignupRequest(@PathParam("username") String username,
161                                    @PathParam("pass-one") String password, @PathParam("mailbox") String mailbox)
162            throws WebApplicationException {
163        String response = "";
164        try {
165            if (currentModuleConfiguration.getChildTopics().getBoolean(CONFIG_EMAIL_CONFIRMATION)) {
166                log.info("Sign-up Configuration: Email based confirmation workflow active, send out confirmation mail.");
167                createUserValidationToken(username, password, mailbox);
168                // redirect user to a "token-info" page
169                throw new WebApplicationException(Response.temporaryRedirect(new URI("/sign-up/token-info")).build());
170            } else {
171                createSimpleUserAccount(username, password, mailbox);
172                if (DM4_ACCOUNTS_ENABLED) {
173                    log.info("Sign-up Configuration: Email based confirmation workflow inactive. The new user account" +
174                            " created is ENABLED.");
175                    // redirecting user to the "your account is now active" page
176                    throw new WebApplicationException(Response.temporaryRedirect(new URI("/sign-up/ok")).build());
177                } else {
178                    log.info("Sign-up Configuration: Email based confirmation workflow inactive but new user account " +
179                            "created is DISABLED.");
180                    // redirecting to page displaying "your account was created but needs to be activated"
181                    throw new WebApplicationException(Response.temporaryRedirect(new URI("/sign-up/pending")).build());
182                }
183            }
184        } catch (URISyntaxException e) {
185            log.log(Level.SEVERE, "Could not build response URI while handling sign-up request", e);
186        }
187        return getFailureView();
188    }
189
190    @GET
191    @Path("/confirm/{token}")
192    public Viewable handleTokenRequest(@PathParam("token") String key) {
193        // 1) Assert token exists: It may not exist due to e.g. bundle refresh, system restart, token invalid
194        if (!token.containsKey(key)) {
195            viewData("username", null);
196            viewData("message", "Sorry, the link is invalid");
197            return getFailureView();
198        }
199        // 2) Process available token and remove it from stack
200        String username;
201        JSONObject input = token.get(key);
202        token.remove(key);
203        // 3) Create the user account and show ok OR present an error message.
204        try {
205            username = input.getString("username");
206            if (input.getLong("expiration") > new Date().getTime()) {
207                log.log(Level.INFO, "Trying to create user account for {0}", input.getString("mailbox"));
208                createSimpleUserAccount(username, input.getString("password"), input.getString("mailbox"));
209            } else {
210                viewData("username", null);
211                viewData("message", "Sorry, the link has expired");
212                return getFailureView();
213            }
214        } catch (JSONException ex) {
215            Logger.getLogger(SignupPlugin.class.getName()).log(Level.SEVERE, null, ex);
216            viewData("message", "An error occured processing your request");
217            log.log(Level.SEVERE, "Account creation failed due to {0} caused by {1}",
218                new Object[]{ex.getMessage(), ex.getCause().toString()});
219            return getFailureView();
220        }
221        log.log(Level.INFO, "Account succesfully created for username: {0}", username);
222        viewData("username", username);
223        viewData("message", "User account created successfully");
224        if (!DM4_ACCOUNTS_ENABLED) {
225            log.log(Level.INFO, "> Account activation by an administrator remains PENDING ");
226            return getAccountCreationPendingView();
227        }
228        return getAccountCreationOKView();
229    }
230
231
232
233    // --- Sign-up Plugin Routes --- //
234
235    @GET
236    @Produces(MediaType.TEXT_HTML)
237    public Viewable getSignupFormView() {
238        // ### use acl service to check if a session already exists and if so, redirect to dm-webclient directly
239        prepareSignupPage();
240        return view("sign-up");
241    }
242
243    @GET
244    @Path("/login")
245    @Produces(MediaType.TEXT_HTML)
246    public Viewable getLoginFormView() {
247        // ### use acl service to check if a session already exists and if so, redirect to dm-webclient directly
248        prepareSignupPage();
249        return view("login");
250    }
251
252    @GET
253    @Path("/ok")
254    @Produces(MediaType.TEXT_HTML)
255    public Viewable getAccountCreationOKView() {
256        prepareSignupPage();
257        return view("ok");
258    }
259
260    @GET
261    @Path("/pending")
262    @Produces(MediaType.TEXT_HTML)
263    public Viewable getAccountCreationPendingView() {
264        prepareSignupPage();
265        return view("pending");
266    }
267
268    @GET
269    @Path("/error")
270    @Produces(MediaType.TEXT_HTML)
271    public Viewable getFailureView() {
272        prepareSignupPage();
273        return view("failure");
274    }
275
276    @GET
277    @Path("/token-info")
278    @Produces(MediaType.TEXT_HTML)
279    public Viewable getConfirmationInfoView() {
280        prepareSignupPage();
281        return view("confirmation");
282    }
283
284
285
286    // --- Private Helpers --- //
287
288    private void createUserValidationToken(@PathParam("username") String username,
289                                           @PathParam("pass-one") String password, @PathParam("mailbox") String mailbox) {
290        //
291        try {
292            String key = UUID.randomUUID().toString();
293            long valid = new Date().getTime() + 3600000; // Token is valid fo 60 min
294            JSONObject value = new JSONObject()
295                    .put("username", username.trim())
296                    .put("mailbox", mailbox.trim())
297                    .put("password", password)
298                    .put("expiration", valid);
299            token.put(key, value);
300            log.log(Level.INFO, "Set up key {0} for {1} sending confirmation mail valid till {3}",
301                    new Object[]{key, mailbox, new Date(valid).toString()});
302            // ### TODO: if sending confirmation mail fails users should know about that and
303            // get to see the "failure" screen next (with a proper message)
304            sendConfirmationMail(key, username, mailbox.trim());
305        } catch (JSONException ex) {
306            Logger.getLogger(SignupPlugin.class.getName()).log(Level.SEVERE, null, ex);
307            throw new RuntimeException(ex);
308        }
309    }
310
311    private String createSimpleUserAccount(@PathParam("username") String username,
312                                           @PathParam("pass-one") String password,
313                                           @PathParam("mailbox") String mailbox) {
314        DeepaMehtaTransaction tx = dms.beginTx();
315        try {
316            if (isUsernameTaken(username)) {
317                // Might be thrown if two users compete for registration (of the same username)
318                // within the same 60 minutes (tokens validity timespan). First confirming, wins.
319                throw new RuntimeException("Username was already registered and confirmed!");
320            }
321            Credentials creds = new Credentials(new JSONObject()
322                .put("username", username)
323                .put("password", password));
324            // 1) Create new user (in which workspace), just within the private one, no?
325            final Topic usernameTopic = acService.createUserAccount(creds);
326            final String eMailAddressValue = mailbox;
327            // 2) create and associate e-mail address topic
328            dms.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() {
329                @Override
330                public Topic call() {
331                    Topic eMailAddress = dms.createTopic(new TopicModel(MAILBOX_TYPE_URI,
332                        new SimpleValue(eMailAddressValue)));
333                    // 3) fire custom event ### this is useless since fired by "anonymous" (this request scope)
334                    dms.fireEvent(USER_ACCOUNT_CREATE_LISTENER, usernameTopic);
335                    AccessControl acCore = dms.getAccessControl();
336                    // 4) assign new e-mail address topic to admins "Private workspace"
337                    Topic adminWorkspace = dms.getAccessControl().getPrivateWorkspace("admin");
338                    acCore.assignToWorkspace(eMailAddress, adminWorkspace.getId());
339                    // 5) associate email address to "username" topic too
340                    Association assoc = dms.createAssociation(new AssociationModel("org.deepamehta.signup.user_mailbox",
341                        new TopicRoleModel(eMailAddress.getId(), "dm4.core.child"),
342                        new TopicRoleModel(usernameTopic.getId(), "dm4.core.parent")));
343                    // 6) assign that association also to admins "Private Workspace"
344                    acCore.assignToWorkspace(assoc, adminWorkspace.getId());
345                    return eMailAddress;
346                }
347            });
348            log.info("Created new user account for user \"" + username + "\" and " + eMailAddressValue);
349            // 7) Inform administrations about successfull account creation
350            sendNotificationMail(username, mailbox.trim());
351            tx.success();
352            return username;
353        } catch (Exception e) {
354            throw new RuntimeException("Creating simple user account FAILED!", e);
355        } finally {
356            tx.finish();
357        }
358    }
359
360    /**
361     * Loads the next sign-up configuration topic for this plugin.
362     *
363     * @see init()
364     * @see postUpdateTopic()
365     */
366    private Topic reloadConfiguration() {
367        log.info("Sign-up: Reloading sign-up plugin configuration.");
368        currentModuleConfiguration = getCurrentSignupConfiguration();
369        currentModuleConfiguration.loadChildTopics();
370        log.log(Level.INFO, "Sign-up: Loaded sign-up configuration => \"{0}\", \"{1}\"",
371            new Object[]{currentModuleConfiguration.getUri(), currentModuleConfiguration.getSimpleValue()});
372        return currentModuleConfiguration;
373    }
374
375    private void sendConfirmationMail(String key, String username, String mailbox) {
376        try {
377            String webAppTitle = currentModuleConfiguration.getChildTopics().getString(CONFIG_WEBAPP_TITLE);
378            URL url = new URL(DM4_HOST_URL);
379            log.info("The confirmation mails token request URL should be:"
380                + "\n" + url + "sign-up/confirm/" + key);
381            if (DM4_ACCOUNTS_ENABLED) {
382                sendSystemMail("Your account on " + webAppTitle,
383                    "Hi " + username + ",\n\nplease click the following link to activate your account. Your account " +
384                            "is ready to use immediately.\n" + url + "sign-up/confirm/" + key
385                        + "\n\nCheers!", mailbox);
386            } else {
387                sendSystemMail("Your account on " + webAppTitle,
388                    "Hi " + username + ",\n\nplease click the following link to proceed with the sign-up process.\n"
389                        + url + "sign-up/confirm/" + key
390                        + "\n\n" + "You'll receive another mail once your account is activated by an " +
391                        "administrator. This may need 1 or 2 days."
392                        + "\n\nCheers!", mailbox);
393            }
394        } catch (MalformedURLException ex) {
395            throw new RuntimeException(ex);
396        }
397    }
398
399    private void sendNotificationMail(String username, String mailbox) {
400        String webAppTitle = currentModuleConfiguration.getChildTopics().getString(CONFIG_WEBAPP_TITLE);
401        //
402        if (currentModuleConfiguration.getChildTopics().has(CONFIG_ADMIN_MAILBOX) &&
403            !currentModuleConfiguration.getChildTopics().getString(CONFIG_ADMIN_MAILBOX).isEmpty()) {
404            String adminMailbox = currentModuleConfiguration.getChildTopics().getString(CONFIG_ADMIN_MAILBOX);
405                sendSystemMail("Account registration on " + webAppTitle,
406                        "\nA user has registered.\n\nUsername: " + username + "\nEmail: " + mailbox + "\n\n" +
407                                DM4_HOST_URL +"\n\n", adminMailbox);
408        } else {
409            log.info("ADMIN: No \"Admin Mailbox\" configured: A new user account (" + username + ") was created but" +
410                    " no notification sent (to sys-admin).");
411        }
412    }
413
414    /**
415     *
416     * @param subject       String Subject text for the message.
417     * @param message       String Text content of the message.
418     * @param recipient     String of Email Address message is sent to **must not** be NULL.
419     */
420    private void sendSystemMail(String subject, String message, String recipient) {
421        // Hot Fix: Classloader issue we have in OSGi since using Pax web
422        Thread.currentThread().setContextClassLoader(SignupPlugin.class.getClassLoader());
423        log.info("BeforeSend: Set classloader to " + Thread.currentThread().getContextClassLoader().toString());
424        HtmlEmail email = new HtmlEmail();
425        email.setDebug(true); // => System.out.println(SMTP communication);
426        email.setHostName("localhost"); // ### use getBaseUri() from HTTP Context?
427        try {
428            // ..) Set Senders of Mail
429            String projectName = currentModuleConfiguration.getChildTopics().getString(CONFIG_PROJECT_TITLE);
430            String sender = currentModuleConfiguration.getChildTopics().getString(CONFIG_FROM_MAILBOX);
431            email.setFrom(sender.trim(), projectName.trim());
432            // ..) Set Subject of Mail
433            email.setSubject(subject);
434            // ..) Set Message Body and append the Host URL
435            message += "\n\n" + DM4_HOST_URL + "\n\n";
436            email.setTextMsg(message);
437            // ..) Set recipient of notification mail
438            String recipientValue = recipient.trim();
439            log.info("Loaded current configuration topic, sending notification mail to " + recipientValue);
440            Collection<InternetAddress> recipients = new ArrayList<InternetAddress>();
441            recipients.add(new InternetAddress(recipientValue));
442            email.setTo(recipients);
443            email.send();
444            log.info("Mail was SUCCESSFULLY sent to " + email.getToAddresses() + " mail addresses");
445        } catch (Exception ex) {
446            throw new RuntimeException("Sending notification mail FAILED", ex);
447        } finally {
448            // Fix: Classloader issue we have in OSGi since using Pax web
449            Thread.currentThread().setContextClassLoader(DeepaMehtaService.class.getClassLoader());
450            log.info("AfterSend: Set Classloader back " + Thread.currentThread().getContextClassLoader().toString());
451        }
452    }
453
454    private boolean isUsernameTaken(String username) {
455        String value = username.trim();
456        Topic userNameTopic = acService.getUsernameTopic(value);
457        return (userNameTopic != null);
458    }
459
460    private boolean isMailboxTaken(String email) {
461        String value = email.toLowerCase().trim();
462        return dms.getAccessControl().emailAddressExists(value);
463    }
464
465    /**
466     * The sign-up configuration object is loaded once when this bundle/plugin
467     * is initialized by the framework and as soon as one configuration was
468     * edited.
469     *
470     * @see reloadConfiguration()
471     */
472    private Topic getCurrentSignupConfiguration() {
473        Topic pluginTopic = dms.getTopic("uri", new SimpleValue(SIGN_UP_PLUGIN_TOPIC_URI));
474        return pluginTopic.getRelatedTopic("dm4.core.association", "dm4.core.default", "dm4.core.default",
475            SIGN_UP_CONFIG_TYPE_URI);
476    }
477
478    private void prepareSignupPage() {
479        if (currentModuleConfiguration != null) {
480            log.info("Preparing views according to current module configuration.");
481            ChildTopics configuration = currentModuleConfiguration.getChildTopics();
482            viewData("title", configuration.getTopic(CONFIG_WEBAPP_TITLE).getSimpleValue().toString());
483            viewData("logo_path", configuration.getTopic(CONFIG_LOGO_PATH).getSimpleValue().toString());
484            viewData("css_path", configuration.getTopic(CONFIG_CSS_PATH).getSimpleValue().toString());
485            viewData("project_name", configuration.getTopic(CONFIG_PROJECT_TITLE).getSimpleValue().toString());
486            viewData("read_more_url", configuration.getTopic(CONFIG_READ_MORE_URL).getSimpleValue().toString());
487            viewData("tos_label", configuration.getTopic(CONFIG_TOS_LABEL).getSimpleValue().toString());
488            viewData("tos_details", configuration.getTopic(CONFIG_TOS_DETAILS).getSimpleValue().toString());
489            viewData("pd_label", configuration.getTopic(CONFIG_PD_LABEL).getSimpleValue().toString());
490            viewData("pd_details", configuration.getTopic(CONFIG_PD_DETAILS).getSimpleValue().toString());
491            viewData("footer", configuration.getTopic(CONFIG_PAGES_FOOTER).getSimpleValue().toString());
492        } else {
493            log.warning("Could not load module configuration!");
494        }
495    }
496
497    public void postUpdateTopic(Topic topic, TopicModel tm, TopicModel tm1) {
498        if (topic.getTypeUri().equals(SIGN_UP_CONFIG_TYPE_URI)) {
499            reloadConfiguration();
500        } else if (topic.getTypeUri().equals(CONFIG_TOPIC_ACCOUNT_ENABLED)) {
501            // Account status
502            boolean status = Boolean.parseBoolean(topic.getSimpleValue().toString());
503            // Account involved
504            Topic username = topic.getRelatedTopic("dm4.config.configuration", null,
505                    null, "dm4.accesscontrol.username");
506            // Perform notification
507            if (status && !DM4_ACCOUNTS_ENABLED) { // Enabled=true && new_accounts_are_enabled=false
508                log.info("Sign-up Notification: User Account \"" + username.getSimpleValue()+"\" is now ENABLED!");
509                //
510                String webAppTitle = currentModuleConfiguration.getChildTopics().getTopic(CONFIG_WEBAPP_TITLE)
511                        .getSimpleValue().toString();
512                Topic mailbox = username.getRelatedTopic("org.deepamehta.signup.user_mailbox", null, null,
513                        MAILBOX_TYPE_URI);
514                if (mailbox != null) { // for accounts created via sign-up plugin this will always evaluate to true
515                    String mailboxValue = mailbox.getSimpleValue().toString();
516                    sendSystemMail("Your account on " + webAppTitle + " is now active",
517                            "Hi " + username.getSimpleValue() + ",\n\nyour account on " + DM4_HOST_URL + " is now " +
518                                    "active.\n\nCheers!", mailboxValue);
519                    log.info("Send system notification mail to " + mailboxValue + " - The account is now active!");
520                }
521            }
522        }
523    }
524
525}