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
-
-
+
+
Password Recovery
+
{{recovery_message}}
+
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