diff --git a/config.dev.yml b/config.dev.yml index 677542e8..b5b355be 100644 --- a/config.dev.yml +++ b/config.dev.yml @@ -17,11 +17,11 @@ app: databaseUpdateThreads: 1 # settings for sending emails (password recovery) - smtpHost: - smtpPort: + smtpHost: localhost + smtpPort: 25 smtpTls: false - smtpUserName: - smtpPassword: + smtpUserName: user + smtpPassword: pass # wether this commafeed instance has a lot of feeds to refresh # leave this to false in almost all cases diff --git a/src/main/app/images/preview.jpg b/src/main/app/images/preview.jpg index ef05d5d8..f6094775 100644 Binary files a/src/main/app/images/preview.jpg and b/src/main/app/images/preview.jpg differ diff --git a/src/main/app/js/controllers.js b/src/main/app/js/controllers.js index 1f74d759..d3daa163 100644 --- a/src/main/app/js/controllers.js +++ b/src/main/app/js/controllers.js @@ -1460,9 +1460,16 @@ module.controller('MetricsCtrl', ['$scope', 'AdminMetricsService', function($sco $scope.metrics = AdminMetricsService.get(); }]); -module.controller('LoginCtrl', ['$scope', '$location', 'SessionService', function($scope, $location, SessionService) { +module.controller('LoginCtrl', ['$scope', '$location', 'SessionService', 'ServerService', function($scope, $location, SessionService, ServerService) { $scope.model = {}; - + $scope.recovery_model = {}; + $scope.recovery = false; + $scope.recovery_enabled = false; + + ServerService.get(function(data) { + $scope.recovery_enabled = data.smtpEnabled; + }); + var login = function(model) { var success = function(data) { window.location.href = window.location.href.substring(0, window.location.href.lastIndexOf('#')); @@ -1485,6 +1492,22 @@ module.controller('LoginCtrl', ['$scope', '$location', 'SessionService', functio $scope.login = function() { login($scope.model); }; + + $scope.toggleRecovery = function() { + $scope.recovery = !$scope.recovery; + }; + + var recovery_success = function(data) { + $scope.recovery_message = "Email has ben sent. Check your inbox."; + }; + var recovery_error = function(data) { + $scope.recovery_message = data.data; + }; + $scope.recover = function() { + SessionService.passwordReset({ + email : $scope.recovery_model.email + }, recovery_success, recovery_error); + } }]); module.controller('RegisterCtrl', ['$scope', '$location', 'SessionService', 'ServerService', diff --git a/src/main/app/js/services.js b/src/main/app/js/services.js index 39d473f6..dbcba6ec 100644 --- a/src/main/app/js/services.js +++ b/src/main/app/js/services.js @@ -36,6 +36,7 @@ module.factory('SessionService', ['$resource', function($resource) { var res = {}; res.login = $resource('rest/user/login').save; res.register = $resource('rest/user/register').save; + res.passwordReset = $resource('rest/user/passwordReset').save; return res; }]); diff --git a/src/main/app/sass/generic/_misc.scss b/src/main/app/sass/generic/_misc.scss index f3fbed9a..a60f5dbe 100644 --- a/src/main/app/sass/generic/_misc.scss +++ b/src/main/app/sass/generic/_misc.scss @@ -35,6 +35,10 @@ label { color: inherit; } +.clear-both { + clear: both; +} + .block { display: block; } @@ -63,7 +67,11 @@ label { } .welcome .header { - margin: 20px 0 40px 0; + margin: 20px 0 20px 0; +} + +.welcome h3 { + margin-top: 0; } .welcome .tagline { @@ -73,13 +81,14 @@ label { } .welcome .preview { - margin: 20px 0 20px 0; - max-width: 100%; + margin-top: 20px; + margin-bottom: 20px; } .welcome .demo { font-size: 24px; color: #B3B3B3; + display: block; } .main .spinner { diff --git a/src/main/app/templates/welcome.html b/src/main/app/templates/welcome.html index ab54c0a3..b67e679a 100644 --- a/src/main/app/templates/welcome.html +++ b/src/main/app/templates/welcome.html @@ -6,21 +6,18 @@
Bloat-free feed reader
-
- - +
+ + + Try out the demo
- - Try out the demo -
- (some features are disabled) -
+ (some features are disabled)
-
+

Login

@@ -34,7 +31,21 @@
- Forgot password? + Forgot password? +
+ +
+
+

Password Recovery

+ +
+
+ + +
+
+ +
diff --git a/src/main/java/com/commafeed/CommaFeedApplication.java b/src/main/java/com/commafeed/CommaFeedApplication.java index 1aaba840..5eaf4a5b 100644 --- a/src/main/java/com/commafeed/CommaFeedApplication.java +++ b/src/main/java/com/commafeed/CommaFeedApplication.java @@ -60,6 +60,7 @@ import com.commafeed.backend.service.FeedEntryTagService; import com.commafeed.backend.service.FeedService; import com.commafeed.backend.service.FeedSubscriptionService; import com.commafeed.backend.service.FeedUpdateService; +import com.commafeed.backend.service.MailService; import com.commafeed.backend.service.PasswordEncryptionService; import com.commafeed.backend.service.PubSubService; import com.commafeed.backend.service.StartupService; @@ -160,6 +161,7 @@ public class CommaFeedApplication extends Application { FeedSubscriptionService feedSubscriptionService = new FeedSubscriptionService(feedEntryStatusDAO, feedSubscriptionDAO, feedService, queues, cacheService, config); FeedUpdateService feedUpdateService = new FeedUpdateService(feedEntryDAO, feedEntryContentService); + MailService mailService = new MailService(config); PasswordEncryptionService encryptionService = new PasswordEncryptionService(); PubSubService pubSubService = new PubSubService(config, queues); UserService userService = new UserService(feedCategoryDAO, userDAO, userSettingsDAO, feedSubscriptionService, encryptionService, @@ -198,7 +200,8 @@ public class CommaFeedApplication extends Application { feedSubscriptionService, queues, opmlImporter, opmlExporter, cacheService, config)); environment.jersey().register(new PubSubHubbubCallbackREST(feedDAO, feedParser, queues, config, metrics)); environment.jersey().register(new ServerREST(httpGetter, config, applicationPropertiesService)); - environment.jersey().register(new UserREST(userDAO, userRoleDAO, userSettingsDAO, userService, encryptionService)); + environment.jersey().register( + new UserREST(userDAO, userRoleDAO, userSettingsDAO, userService, encryptionService, mailService, config)); // Servlets NextUnreadServlet nextUnreadServlet = new NextUnreadServlet(sessionFactory, feedSubscriptionDAO, feedEntryStatusDAO, @@ -230,8 +233,6 @@ public class CommaFeedApplication extends Application { SwaggerConfig swaggerConfig = ConfigFactory.config(); swaggerConfig.setApiVersion("1"); swaggerConfig.setBasePath("/rest"); - - // TODO password recovery } public static void main(String[] args) throws Exception { diff --git a/src/main/java/com/commafeed/backend/service/MailService.java b/src/main/java/com/commafeed/backend/service/MailService.java index 929309f5..96f14029 100644 --- a/src/main/java/com/commafeed/backend/service/MailService.java +++ b/src/main/java/com/commafeed/backend/service/MailService.java @@ -3,7 +3,6 @@ package com.commafeed.backend.service; import java.io.Serializable; import java.util.Properties; -import javax.inject.Inject; import javax.mail.Authenticator; import javax.mail.Message; import javax.mail.PasswordAuthentication; @@ -12,6 +11,8 @@ import javax.mail.Transport; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; + import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration.ApplicationSettings; import com.commafeed.backend.model.User; @@ -21,10 +22,10 @@ import com.commafeed.backend.model.User; * */ @SuppressWarnings("serial") +@RequiredArgsConstructor public class MailService implements Serializable { - @Inject - CommaFeedConfiguration config; + private final CommaFeedConfiguration config; public void sendMail(User user, String subject, String content) throws Exception { diff --git a/src/main/java/com/commafeed/frontend/model/ServerInfo.java b/src/main/java/com/commafeed/frontend/model/ServerInfo.java index 39ba4206..2a3c24d8 100644 --- a/src/main/java/com/commafeed/frontend/model/ServerInfo.java +++ b/src/main/java/com/commafeed/frontend/model/ServerInfo.java @@ -15,5 +15,7 @@ public class ServerInfo implements Serializable { private String version; private String gitCommit; private boolean allowRegistrations; + private String googleAnalyticsCode; + private boolean smtpEnabled; } diff --git a/src/main/java/com/commafeed/frontend/model/request/PasswordResetRequest.java b/src/main/java/com/commafeed/frontend/model/request/PasswordResetRequest.java new file mode 100644 index 00000000..506df3e8 --- /dev/null +++ b/src/main/java/com/commafeed/frontend/model/request/PasswordResetRequest.java @@ -0,0 +1,22 @@ +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import lombok.Data; + +import org.hibernate.validator.constraints.Email; +import org.hibernate.validator.constraints.NotEmpty; + +import com.wordnik.swagger.annotations.ApiModel; +import com.wordnik.swagger.annotations.ApiModelProperty; + +@SuppressWarnings("serial") +@Data +@ApiModel +public class PasswordResetRequest implements Serializable { + + @ApiModelProperty(value = "email address for password recovery", required = true) + @Email + @NotEmpty + private String email; +} diff --git a/src/main/java/com/commafeed/frontend/resource/ServerREST.java b/src/main/java/com/commafeed/frontend/resource/ServerREST.java index a78d25b4..ce171124 100644 --- a/src/main/java/com/commafeed/frontend/resource/ServerREST.java +++ b/src/main/java/com/commafeed/frontend/resource/ServerREST.java @@ -13,6 +13,8 @@ import javax.ws.rs.core.Response.Status; import lombok.AllArgsConstructor; +import org.apache.commons.lang.StringUtils; + import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.HttpGetter; import com.commafeed.backend.HttpGetter.HttpResult; @@ -45,6 +47,8 @@ public class ServerREST { infos.setVersion(applicationPropertiesService.getVersion()); infos.setGitCommit(applicationPropertiesService.getGitCommit()); infos.setAllowRegistrations(config.getApplicationSettings().isAllowRegistrations()); + infos.setGoogleAnalyticsCode(config.getApplicationSettings().getGoogleAnalyticsTrackingCode()); + infos.setSmtpEnabled(StringUtils.isNotBlank(config.getApplicationSettings().getSmtpHost())); return Response.ok(infos).build(); } diff --git a/src/main/java/com/commafeed/frontend/resource/UserREST.java b/src/main/java/com/commafeed/frontend/resource/UserREST.java index cca7b6b3..ba16734f 100644 --- a/src/main/java/com/commafeed/frontend/resource/UserREST.java +++ b/src/main/java/com/commafeed/frontend/resource/UserREST.java @@ -6,6 +6,8 @@ import io.dropwizard.jersey.validation.ValidationErrorMessage; import java.util.Arrays; import java.util.Collections; +import java.util.Date; +import java.util.UUID; import javax.servlet.http.HttpSession; import javax.validation.ConstraintViolation; @@ -15,18 +17,26 @@ import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang.RandomStringUtils; import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang.time.DateUtils; +import org.apache.http.client.utils.URIBuilder; import com.commafeed.CommaFeedApplication; +import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserRoleDAO; import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.model.User; import com.commafeed.backend.model.UserRole; import com.commafeed.backend.model.UserRole.Role; @@ -34,12 +44,14 @@ import com.commafeed.backend.model.UserSettings; import com.commafeed.backend.model.UserSettings.ReadingMode; import com.commafeed.backend.model.UserSettings.ReadingOrder; import com.commafeed.backend.model.UserSettings.ViewMode; +import com.commafeed.backend.service.MailService; import com.commafeed.backend.service.PasswordEncryptionService; import com.commafeed.backend.service.UserService; import com.commafeed.frontend.auth.SecurityCheck; import com.commafeed.frontend.model.Settings; import com.commafeed.frontend.model.UserModel; import com.commafeed.frontend.model.request.LoginRequest; +import com.commafeed.frontend.model.request.PasswordResetRequest; import com.commafeed.frontend.model.request.ProfileModificationRequest; import com.commafeed.frontend.model.request.RegistrationRequest; import com.google.common.base.Optional; @@ -53,6 +65,7 @@ import com.wordnik.swagger.annotations.ApiParam; @Api(value = "/user", description = "Operations about the user") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@Slf4j @AllArgsConstructor public class UserREST { @@ -61,6 +74,8 @@ public class UserREST { private final UserSettingsDAO userSettingsDAO; private final UserService userService; private final PasswordEncryptionService encryptionService; + private final MailService mailService; + private final CommaFeedConfiguration config; @Path("/settings") @GET @@ -233,6 +248,75 @@ public class UserREST { } } + @Path("/passwordReset") + @POST + @UnitOfWork + @ApiOperation(value = "send a password reset email") + public Response sendPasswordReset(@Valid PasswordResetRequest req) { + User user = userDAO.findByEmail(req.getEmail()); + if (user == null) { + return Response.status(Status.PRECONDITION_FAILED).entity("Email not found.").build(); + } + try { + user.setRecoverPasswordToken(DigestUtils.sha1Hex(UUID.randomUUID().toString())); + user.setRecoverPasswordTokenDate(new Date()); + userDAO.saveOrUpdate(user); + mailService.sendMail(user, "Password recovery", buildEmailContent(user)); + return Response.ok().build(); + } catch (Exception e) { + log.error(e.getMessage(), e); + return Response.status(Status.INTERNAL_SERVER_ERROR).entity("could not send email: " + e.getMessage()).build(); + } + } + + private String buildEmailContent(User user) throws Exception { + String publicUrl = FeedUtils.removeTrailingSlash(config.getApplicationSettings().getPublicUrl()); + publicUrl += "/rest/user/passwordResetCallback"; + return String + .format("You asked for password recovery for account '%s', follow this link to change your password. Ignore this if you didn't request a password recovery.", + user.getName(), callbackUrl(user, publicUrl)); + } + + private String callbackUrl(User user, String publicUrl) throws Exception { + return new URIBuilder(publicUrl).addParameter("email", user.getEmail()).addParameter("token", user.getRecoverPasswordToken()) + .build().toURL().toString(); + } + + @Path("/passwordResetCallback") + @GET + @UnitOfWork + @Produces(MediaType.TEXT_HTML) + public Response passwordRecoveryCallback(@QueryParam("email") String email, @QueryParam("token") String token) { + Preconditions.checkNotNull(email); + Preconditions.checkNotNull(token); + + User user = userDAO.findByEmail(email); + if (user == null) { + return Response.status(Status.UNAUTHORIZED).entity("Email not found.").build(); + } + if (user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) { + return Response.status(Status.UNAUTHORIZED).entity("Invalid token.").build(); + } + if (user.getRecoverPasswordTokenDate().before(DateUtils.addDays(new Date(), -2))) { + return Response.status(Status.UNAUTHORIZED).entity("token expired.").build(); + } + + String passwd = RandomStringUtils.randomAlphanumeric(10); + byte[] password = encryptionService.getEncryptedPassword(passwd, user.getSalt()); + user.setPassword(password); + if (StringUtils.isNotBlank(user.getApiKey())) { + user.setApiKey(userService.generateApiKey(user)); + } + user.setRecoverPasswordToken(null); + user.setRecoverPasswordTokenDate(null); + userDAO.saveOrUpdate(user); + + String message = "Your new password is: " + passwd; + message += "
"; + message += String.format("Back to Homepage", config.getApplicationSettings().getPublicUrl()); + return Response.ok(message).build(); + } + @Path("/profile/deleteAccount") @POST @UnitOfWork