Skip to content

Commit

Permalink
Feat/change password (#141)
Browse files Browse the repository at this point in the history
* feat(change-password): added a menu for logged in users to change password

* feat(change-password): send mail code

* feat(change-password): add new services

* feat(change-password): feature implemented

* feat(change-password): handle password > 20 characters error and update email message

* feat(change-password): put reset code logic in session

* feat(change-password): add enforcedWebapRootUrl var

* Sorted out a few details on password override functionality

---------

Co-authored-by: Dorian Grasset <[email protected]>
  • Loading branch information
GuilhemSempere and dorian-grst authored Aug 19, 2024
1 parent 865a0bf commit 74ab442
Show file tree
Hide file tree
Showing 11 changed files with 345 additions and 24 deletions.
20 changes: 19 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,25 @@
<packaging>war</packaging>
<name>Gigwa</name>

<dependencies>
<dependencies>
<dependency>
<groupId>javax.mail</groupId>
<artifactId>mail</artifactId>
<version>1.4.7</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>5.3.25</version>
</dependency>

<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<version>1.1.2</version>
</dependency>

<dependency>
<groupId>fr.cirad</groupId>
<artifactId>role_manager</artifactId>
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/fr/cirad/security/ResetCodeExpirationFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package fr.cirad.security;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.time.LocalDateTime;

public class ResetCodeExpirationFilter implements Filter {

private static final String RESET_EXPIRATION_KEY = "resetExpiration";

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpSession session = httpRequest.getSession(false);

if (session != null) {
LocalDateTime expiration = (LocalDateTime) session.getAttribute(RESET_EXPIRATION_KEY);
if (expiration != null && expiration.isBefore(LocalDateTime.now())) {
// Clear expired reset information
session.removeAttribute("resetCode");
session.removeAttribute("resetEmail");
session.removeAttribute(RESET_EXPIRATION_KEY);
}
}

chain.doFilter(request, response);
}
}
113 changes: 113 additions & 0 deletions src/main/java/fr/cirad/service/PasswordResetService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package fr.cirad.service;

import fr.cirad.security.ReloadableInMemoryDaoImpl;
import fr.cirad.security.UserWithMethod;
import fr.cirad.tools.AppConfig;
import fr.cirad.web.controller.BackOfficeController;

import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.time.LocalDateTime;

@Service
public class PasswordResetService {

private static final Logger LOG = Logger.getLogger(PasswordResetService.class);

private static final String RESET_CODE_KEY = "resetCode";
private static final String RESET_EMAIL_KEY = "resetEmail";
private static final String RESET_EXPIRATION_KEY = "resetExpiration";

@Autowired
private JavaMailSender mailSender;

@Autowired
private AppConfig appConfig;

@Autowired
private ReloadableInMemoryDaoImpl userDao;

public String generateResetCode() {
return String.format("%08d", new java.util.Random().nextInt(100000000));
}

public boolean sendResetPasswordEmail(String email, HttpSession session, HttpServletRequest request) {
UserWithMethod user = userDao.getUserWithMethodByEmailAddress(email);
if (user == null) {
return true; // We return true to not disclose if the email exists
}

String resetCode = generateResetCode();
session.setAttribute(RESET_CODE_KEY, resetCode);
session.setAttribute(RESET_EMAIL_KEY, email);
session.setAttribute(RESET_EXPIRATION_KEY, LocalDateTime.now().plusMinutes(5));

try {
SimpleMailMessage message = new SimpleMailMessage();

String sWebAppRoot = appConfig.get("enforcedWebapRootUrl");
String enforcedWebapRootUrl = (sWebAppRoot == null ? BackOfficeController.determinePublicHostName(request) + request.getContextPath() : sWebAppRoot);
if (enforcedWebapRootUrl == null || enforcedWebapRootUrl.trim().isEmpty())
LOG.warn("enforcedWebapRootUrl is not set in the application.properties file. Using the default value.");

String subject = "Gigwa - Password reset request";
String emailContent = "Hello,\n\nYou have requested to reset your password for the following Gigwa instance: " + enforcedWebapRootUrl + "\nYour password reset code is: " + resetCode + "\nPlease enter this code in the application to reset your password. This code will expire in 5 minutes.\n";

message.setTo(email);
message.setSubject(subject);
message.setText(emailContent);

mailSender.send(message);
return true;
} catch (Exception e) {
LOG.error("Unable to send password reset email", e);
return false;
}
}

public boolean validateResetCode(String code, HttpSession session) {
String storedCode = (String) session.getAttribute(RESET_CODE_KEY);
LocalDateTime expiration = (LocalDateTime) session.getAttribute(RESET_EXPIRATION_KEY);

if (storedCode == null || !storedCode.equals(code) || expiration == null) {
return false;
}

return expiration.isAfter(LocalDateTime.now());
}

public boolean updatePassword(String code, String newPassword, HttpSession session) {
if (!validateResetCode(code, session)) {
return false;
}

String email = (String) session.getAttribute(RESET_EMAIL_KEY);
UserWithMethod user = userDao.getUserWithMethodByEmailAddress(email);
if (user == null) {
return false;
}

try {
userDao.saveOrUpdateUser(user.getUsername(), newPassword,
user.getAuthorities(),
user.isEnabled(), user.getMethod(), user.getEmail());
} catch (IOException e) {
e.printStackTrace();
return false;
}

// Clear the reset information from the session
session.removeAttribute(RESET_CODE_KEY);
session.removeAttribute(RESET_EMAIL_KEY);
session.removeAttribute(RESET_EXPIRATION_KEY);

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,35 @@
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Random;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import fr.cirad.security.GigwaAuthenticationSuccessHandler;
import fr.cirad.service.PasswordResetService;

@Controller
public class GigwaAuthenticationController {
private static final String LOGIN_LOST_PASSWORD_URL = "/lostPassword.do";
private static final String LOGIN_RESET_PASSWORD_URL = "/resetPassword.do";
private static final String LOGIN_CAS_URL = "/login/cas.do";
private static final String LOGIN_FORM_URL = "/login.do";

@Autowired GigwaAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private PasswordResetService passwordResetService;

@Autowired
private GigwaAuthenticationSuccessHandler authenticationSuccessHandler;

@GetMapping(LOGIN_FORM_URL)
public String loginFormPath(HttpServletRequest request, HttpServletResponse response) {
Expand All @@ -32,9 +43,7 @@ public String loginFormPath(HttpServletRequest request, HttpServletResponse resp
String redirectUrl = URLEncoder.encode(targetUrl, StandardCharsets.UTF_8.name());
request.setAttribute("loginOrigin", redirectUrl);
} catch (UnsupportedEncodingException ignored) {}
//authenticationSuccessHandler.getRequestCache().removeRequest(request, response);
}

return "login";
}

Expand All @@ -50,4 +59,33 @@ public String casLoginPath(@RequestParam(name="url", required=false) String redi
return "redirect:/index.jsp";
}
}

@GetMapping(LOGIN_LOST_PASSWORD_URL)
public String lostPasswordForm() {
return "lostPassword";
}

@PostMapping(LOGIN_LOST_PASSWORD_URL)
public String sendResetPasswordEmail(@RequestParam String email, HttpSession session, HttpServletRequest request, Model model) {
passwordResetService.sendResetPasswordEmail(email, session, request);
model.addAttribute("message", "If this e-mail address matches a user account, a 5-minute valid code has just been sent to it.");
return "resetPassword";
}

@PostMapping(LOGIN_RESET_PASSWORD_URL)
public String resetPassword(@RequestParam String code, @RequestParam String newPassword, HttpSession session, Model model) {
if (newPassword.length() > 20) {
model.addAttribute("error", "Password must not exceed 20 characters.");
return "resetPassword";
}

boolean updated = passwordResetService.updatePassword(code, newPassword, session);
if (updated) {
model.addAttribute("message", "Password updated successfully. You can now login.");
return "login";
} else {
model.addAttribute("error", "Invalid or expired code. Please try again.");
return "resetPassword";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public void decide(Authentication authentication, Object object, Collection<Conf
String sModule = fi.getRequest().getParameter("module");
if (sModule != null && MongoTemplateManager.get(sModule) != null && !MongoTemplateManager.isModulePublic(sModule))
{
boolean fIsAnonymous = authorities != null && authorities.contains(new SimpleGrantedAuthority("ROLE_ANONYMOUS"));
boolean fIsAnonymous = authorities != null && authorities.contains(new SimpleGrantedAuthority(IRoleDefinition.ROLE_ANONYMOUS));
boolean fIsAdmin = authorities != null && authorities.contains(new SimpleGrantedAuthority(IRoleDefinition.ROLE_ADMIN));
boolean fHasRequiredRole;

Expand Down
25 changes: 21 additions & 4 deletions src/main/resources/applicationContext-MVC.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

<bean class="com.fasterxml.jackson.databind.ObjectMapper" />
<bean class="com.fasterxml.jackson.databind.ObjectMapper" />

<mvc:annotation-driven>
<mvc:message-converters>
<bean class="fr.cirad.rest.json.CustomMappingJackson2HttpMessageConverter" />
</mvc:message-converters>
</mvc:annotation-driven>

<!-- Controllers, MongoTemplateManager,timer and api documentation-->
<context:component-scan base-package="fr.cirad.mgdb.service,fr.cirad.web.controller,fr.cirad.manager,fr.cirad.configuration,org.brapi.v2.api" />
<!-- Controllers, MongoTemplateManager,timer, api documentation, controller and service -->
<context:component-scan base-package="fr.cirad.mgdb.service,fr.cirad.web.controller,fr.cirad.manager,fr.cirad.configuration,org.brapi.v2.api, fr.cirad.web.controller, fr.cirad.service" />

<bean class="fr.cirad.tools.GlobalExceptionHandler" />

Expand All @@ -42,5 +42,22 @@
<prop key="java.lang.AccessDeniedException">/error/403</prop>
</props>
</property>
</bean>
</bean>

<bean class="org.springframework.mail.javamail.JavaMailSenderImpl">
<property name="defaultEncoding" value="UTF-8"/>
<property name="host" value="smtp.cirad.fr"/>
<!-- <property name="username" value="UN"/> -->
<!-- <property name="password" value="PW"/> -->
<property name="port" value="25"/> <!-- set this to 587 if you are using authentication -->
<property name="javaMailProperties">
<value>
mail.debug=true
mail.smtp.auth=false
mail.smtp.starttls.enable=false
mail.smtp.ssl.enable=false
</value>
</property>
</bean>

</beans>
27 changes: 14 additions & 13 deletions src/main/webapp/WEB-INF/jsp/login.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@
<div class="panel panel-default">
<div class="panel-body text-center">
<div style="background-color:white; padding:7px; border:darkblue 5px outset; margin:10px 0 40px 0;"><img alt="Gigwa" height="40" src="images/logo_big.png" /><br/>LOGIN FORM</div>
<form name="f" action='login' method='POST' id="form-login">
<form name="f" action='login' method='POST' id="form-login" style="">
<input type="text" name="username" id="username" placeholder="Username" required="required" />
<input type="password" name="password" id="password" placeholder="Password" required="required" />
<button type="submit" name="connexion" class="btn btn-primary btn-block btn-large">Log me in</button>
<a class="text-danger" style="font-size:13px;" href="${pageContext.request.contextPath}/lostPassword.do">Lost your password?</a>
<button type="submit" name="connexion" class="btn btn-primary btn-block btn-large" style="margin:40px 0 20px 0;">Log me in</button>
</form>
<c:set var="casServerURL" value="<%= appConfig.get(\"casServerURL\") %>"></c:set>
<c:set var="enforcedWebapRootUrl" value="<%= appConfig.get(\"enforcedWebapRootUrl\") %>"></c:set>
Expand All @@ -73,17 +74,17 @@
<c:choose><c:when test='${!fn:startsWith(casOrganization, "??") && !empty casOrganization}'>${casOrganization}</c:when><c:otherwise>organization</c:otherwise></c:choose>
account</a>
</c:if>
<div class="text-red margin-top-md">
&nbsp;
<c:if test="${param.auth eq 'failure'}">
<span id="loginErrorMsg" style="background-color:white; padding:0 10px;"><c:out value="${SPRING_SECURITY_LAST_EXCEPTION.message}" /></span>
<script type="text/javascript">
setTimeout(function () {
$("span#loginErrorMsg").fadeTo(1000, 0);
}, 2000);
</script>
</c:if>
</div>
<c:if test="${param.auth eq 'failure'}">
<div class="text-red margin-top-md">
&nbsp;
<span id="loginErrorMsg" style="background-color:white; padding:0 10px;"><c:out value="${SPRING_SECURITY_LAST_EXCEPTION.message}" /></span>
<script type="text/javascript">
setTimeout(function () {
$("span#loginErrorMsg").fadeTo(1000, 0);
}, 2000);
</script>
</div>
</c:if>
<button type="button" class="btn btn-primary btn-block btn-large margin-top-md" onclick="window.location.href = 'index.jsp';">Return to public databases</button>
<c:set var="adminEmail" value="<%= appConfig.get(\"adminEmail\") %>"></c:set>
<c:if test='${!fn:startsWith(adminEmail, "??") && !empty adminEmail}'>
Expand Down
38 changes: 38 additions & 0 deletions src/main/webapp/WEB-INF/jsp/lostPassword.jsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!DOCTYPE html>

<%@ page language="java" contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>

<html>
<head>
<meta charset="utf-8">
<title>Gigwa - Lost password</title>
<link rel="shortcut icon" href="images/favicon.png" type="image/x-icon" />
<link type="text/css" rel="stylesheet" href="css/bootstrap.min.css">
<link type="text/css" rel="stylesheet" href="css/main.css">
<link type="text/css" rel="stylesheet" href="css/login.css">
<script type="text/javascript" src="js/jquery-1.12.4.min.js"></script>
</head>
<body>
<div class="container">
<div class="row margin-top">
<div class="col-md-4"></div>
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-body text-center">
<div style="background-color:white; padding:7px; border:darkblue 5px outset; margin:10px 0 40px 0;"><img alt="Gigwa" height="40" src="images/logo_big.png" /><br/>RESET PASSWORD</div>
<form action="lostPassword.do" method="POST">
<input type="email" name="email" placeholder="Email address" required />
<c:if test="${not empty error}">
<p class="text-danger">${error}</p>
</c:if>
<button type="submit" class="btn btn-primary btn-block btn-large" style="margin:40px 0 20px 0;">Send reset code</button>
</form>
<a type="button" class="btn btn-primary btn-block btn-large margin-top-md" href="${pageContext.request.contextPath}/login.do">Return to login</a>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
Loading

0 comments on commit 74ab442

Please sign in to comment.