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}