前言
在前面的博文 Spring Boot2 實戰系列之登錄註冊(二) - 登錄實現 中實現了登錄功能。這次繼續完善常用的功能,就是在註冊的時候可以向註冊郵箱發送一個鏈接,打開該鏈接才能激活該賬戶。還有就是密碼重置的功能。
項目架構
項目結構圖如下:
pom 依賴如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>top.yekongle</groupId>
<artifactId>springboot-activate-account-sample</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-activate-account-sample</name>
<description>Activate account by email for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<passay.version>1.5.0</passay.version>
<guava.version>29.0-jre</guava.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.passay</groupId>
<artifactId>passay</artifactId>
<version>${passay.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
代碼編寫
application.properties
spring.mail.host=smtp.163.com
spring.mail.username=your_username
spring.mail.password=your_password
spring.mail.default-encoding=UTF-8
spring.mail.protocol=smtp
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true
support.email=your_username
# 國際化i18n配置,(包名.基礎名)
spring.messages.basename=i18n.messages
spring.messages.encoding=UTF-8
# Thymeleaf 配置
# 禁止緩存
spring.thymeleaf.cache=false
這裏主要寫出改動或新增的類,其他的則和登錄實現篇基本一致, 具體請查看倉庫
PasswordResetToken.java, 密碼重置校驗 Token
package top.yekongle.activate.entity;
import java.util.Calendar;
import java.util.Date;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
@Entity
public class PasswordResetToken {
private static final int EXPIRATION = 60 * 24;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String token;
@OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
@JoinColumn(nullable = false, name = "user_id")
private User user;
private Date expiryDate;
public PasswordResetToken() {
super();
}
public PasswordResetToken(final String token) {
super();
this.token = token;
this.expiryDate = calculateExpiryDate(EXPIRATION);
}
public PasswordResetToken(final String token, final User user) {
super();
this.token = token;
this.user = user;
this.expiryDate = calculateExpiryDate(EXPIRATION);
}
//
public Long getId() {
return id;
}
public String getToken() {
return token;
}
public void setToken(final String token) {
this.token = token;
}
public User getUser() {
return user;
}
public void setUser(final User user) {
this.user = user;
}
public Date getExpiryDate() {
return expiryDate;
}
public void setExpiryDate(final Date expiryDate) {
this.expiryDate = expiryDate;
}
private Date calculateExpiryDate(final int expiryTimeInMinutes) {
final Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(new Date().getTime());
cal.add(Calendar.MINUTE, expiryTimeInMinutes);
return new Date(cal.getTime().getTime());
}
public void updateToken(final String token) {
this.token = token;
this.expiryDate = calculateExpiryDate(EXPIRATION);
}
//
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((expiryDate == null) ? 0 : expiryDate.hashCode());
result = prime * result + ((token == null) ? 0 : token.hashCode());
result = prime * result + ((user == null) ? 0 : user.hashCode());
return result;
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final PasswordResetToken other = (PasswordResetToken) obj;
if (expiryDate == null) {
if (other.expiryDate != null) {
return false;
}
} else if (!expiryDate.equals(other.expiryDate)) {
return false;
}
if (token == null) {
if (other.token != null) {
return false;
}
} else if (!token.equals(other.token)) {
return false;
}
if (user == null) {
if (other.user != null) {
return false;
}
} else if (!user.equals(other.user)) {
return false;
}
return true;
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder();
builder.append("Token [String=").append(token).append("]").append("[Expires").append(expiryDate).append("]");
return builder.toString();
}
}
UserController.java, 註冊用戶,激活賬號,重置密碼
package top.yekongle.activate.controller;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.MessageSource;
import org.springframework.core.env.Environment;
import org.springframework.mail.MailAuthenticationException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.ModelAndView;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.servlet.mvc.support.RedirectAttributesModelMap;
import top.yekongle.activate.dto.PasswordDto;
import top.yekongle.activate.dto.UserDTO;
import top.yekongle.activate.entity.PasswordResetToken;
import top.yekongle.activate.entity.User;
import top.yekongle.activate.entity.VerificationToken;
import top.yekongle.activate.event.OnRegistrationCompleteEvent;
import top.yekongle.activate.exception.UserAlreadyExistException;
import top.yekongle.activate.service.UserService;
import top.yekongle.activate.util.GenericResponse;
import java.util.Calendar;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
/**
* @Author: Yekongle
* @Date: 2020年5月5日
*/
@Slf4j
@Controller
public class UserController {
@Autowired UserService userService;
@Autowired
private MessageSource messages;
@Autowired
LocaleResolver localeResolver;
@Autowired
ApplicationEventPublisher eventPublisher;
@Autowired
private JavaMailSender mailSender;
@Autowired
private Environment env;
@Autowired
private UserDetailsService userDetailsService;
// 註冊頁面
@GetMapping("/registration")
public String registration(Model model) {
model.addAttribute("formTitle", "註冊");
return "registration";
}
// 用戶註冊
@PostMapping("/user/registration")
@ResponseBody
public GenericResponse registerUserAccount(@Valid UserDTO userDTO, HttpServletRequest request) {
User registered = userService.registerNewUserAccount(userDTO);
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(registered, request.getLocale(), getAppUrl(request)));
return new GenericResponse("success");
}
// 激活用戶賬戶
@GetMapping("/registrationConfirm.html")
public String confirmRegistration(HttpServletRequest request, HttpServletResponse response, RedirectAttributesModelMap model
, @RequestParam("token") String token) {
log.info("confirmRegistration");
Locale locale = localeResolver.resolveLocale(request);
log.info("token:{}" + token);
VerificationToken verificationToken = userService.getVerificationToken(token);
if (verificationToken == null) {
String message = messages.getMessage("auth.message.invalidToken", null, locale);
log.info("message:" + message);
model.addFlashAttribute("errMsg", message);
return "redirect:/badUser.html";
}
User user = verificationToken.getUser();
Calendar cal = Calendar.getInstance();
if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) {
model.addFlashAttribute("message", messages.getMessage("auth.message.expired", null, locale));
model.addFlashAttribute("expired", true);
model.addFlashAttribute("token", token);
return "redirect:/badUser.html";
}
user.setEnabled(true);
userService.saveRegisteredUser(user);
model.addFlashAttribute("message", messages.getMessage("message.accountVerified", null, locale));
return "redirect:/login";
}
// 重新發送註冊令牌
@GetMapping("/user/resendRegistrationToken")
@ResponseBody
public GenericResponse resendRegistrationToken(final HttpServletRequest request, final RedirectAttributesModelMap model, @RequestParam("token") final String existingToken) {
log.info("resendRegistrationToken");
final Locale locale = request.getLocale();
final VerificationToken newToken = userService.generateNewVerificationToken(existingToken);
final User user = userService.getUser(newToken.getToken());
final SimpleMailMessage email = constructResetVerificationTokenEmail(getAppUrl(request), request.getLocale(), newToken, user);
mailSender.send(email);
log.info("message: {}", messages.getMessage("message.resendToken", null, locale));
return new GenericResponse(messages.getMessage("message.resendToken", null, locale));
}
// 重置密碼
@PostMapping("/user/resetPassword")
@ResponseBody
public GenericResponse resetPassword(final HttpServletRequest request, final RedirectAttributesModelMap model
, @RequestParam("email") final String userEmail) {
log.info("resetPassword: {}", userEmail);
final User user = userService.findUserByEmail(userEmail);
if (user == null) {
return new GenericResponse(messages.getMessage("message.userNotFound", null, request.getLocale()));
}
final String token = UUID.randomUUID().toString();
userService.createPasswordResetTokenForUser(user, token);
final SimpleMailMessage email = constructResetTokenEmail(getAppUrl(request), request.getLocale(), token, user);
mailSender.send(email);
return new GenericResponse(messages.getMessage("message.resetPasswordEmail", null, request.getLocale()));
}
// 更換密碼頁面
@GetMapping("/user/changePassword")
public String showChangePassword(final HttpServletRequest request, final RedirectAttributesModelMap model, @RequestParam("id") final long id, @RequestParam("token") final String token) {
log.info("showChangePassword, id:{}, token:{}", id, token);
final Locale locale = request.getLocale();
String result = userService.validatePasswordResetToken(token);
log.info("result:{}", result);
if(result != null) {
String message = messages.getMessage("auth.message." + result, null, locale);
log.info("message:{}", message);
model.addFlashAttribute("errMsg", message);
return "redirect:/login.html?";
} else {
model.addFlashAttribute("token", token);
return "redirect:/updatePassword.html";
}
}
@PostMapping("/user/savePassword")
@ResponseBody
public GenericResponse savePassword(final Locale locale, @Valid PasswordDto passwordDto) {
log.info("savePassword");
String result = userService.validatePasswordResetToken(passwordDto.getToken());
if(result != null) {
return new GenericResponse(messages.getMessage(
"auth.message." + result, null, locale));
}
Optional<User> user = userService.getUserByPasswordResetToken(passwordDto.getToken());
if(user.isPresent()) {
userService.changeUserPassword(user.get(), passwordDto.getNewPassword());
String message = messages.getMessage(
"message.resetPasswordSuc", null, locale);
log.info("message:{}", message);
return new GenericResponse(message);
} else {
return new GenericResponse(messages.getMessage(
"auth.message.invalid", null, locale));
}
}
private String getAppUrl(HttpServletRequest request) {
String appUrl = "http://" + request.getServerName() + ":" + request.getServerPort() + request.getContextPath();
return appUrl;
}
private final SimpleMailMessage constructResetVerificationTokenEmail(final String contextPath, final Locale locale
, final VerificationToken newToken, final User user) {
final String confirmationUrl = contextPath + "/registrationConfirm.html?token=" + newToken.getToken();
log.info("Url: {}", confirmationUrl);
final String message = messages.getMessage("message.resendToken", null, locale);
final SimpleMailMessage email = new SimpleMailMessage();
email.setSubject("Resend Registration Token");
email.setText(message + " \r\n" + confirmationUrl);
email.setTo(user.getEmail());
email.setFrom(env.getProperty("support.email"));
log.info("support.email:{}", env.getProperty("support.email"));
return email;
}
private final SimpleMailMessage constructResetTokenEmail(final String contextPath, final Locale locale, final String token, final User user) {
final String url = contextPath + "/user/changePassword?id=" + user.getId() + "&token=" + token;
log.info("url:{}", url);
final String message = messages.getMessage("message.resetPassword", null, locale);
final SimpleMailMessage email = new SimpleMailMessage();
email.setTo(user.getEmail());
email.setSubject("Reset Password");
email.setText(message + " \r\n" + url);
email.setFrom(env.getProperty("support.email"));
return email;
}
}
UserServiceImpl.java, 用戶賬號操作業務類
package top.yekongle.activate.service.impl;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Optional;
import java.util.UUID;
import javax.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import top.yekongle.activate.dto.UserDTO;
import top.yekongle.activate.entity.PasswordResetToken;
import top.yekongle.activate.entity.User;
import top.yekongle.activate.entity.UserAuthority;
import top.yekongle.activate.entity.VerificationToken;
import top.yekongle.activate.exception.UserAlreadyExistException;
import top.yekongle.activate.repository.PasswordResetTokenRepository;
import top.yekongle.activate.repository.UserAuthorityRepository;
import top.yekongle.activate.repository.UserRepository;
import top.yekongle.activate.repository.VerificationTokenRepository;
import top.yekongle.activate.service.UserService;
/**
* @Description:
* @Author: Yekongle
* @Date: 2020年5月5日
*/
@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private UserAuthorityRepository userAuthorityRepository;
@Autowired
private VerificationTokenRepository tokenRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private PasswordResetTokenRepository passwordTokenRepository;
@Override
public User registerNewUserAccount(UserDTO userDTO) throws UserAlreadyExistException {
if (emailExists(userDTO.getEmail())) {
throw new UserAlreadyExistException("該郵箱已被註冊:" + userDTO.getEmail());
}
log.info("UserDTO:" + userDTO.toString());
User user = new User();
user.setEmail(userDTO.getEmail());
user.setPassword(passwordEncoder.encode(userDTO.getPassword()));
userRepository.save(user);
UserAuthority userAuthority = new UserAuthority();
userAuthority.setUsername(userDTO.getEmail());
userAuthority.setRole("ROLE_USER");
userAuthorityRepository.save(userAuthority);
return user;
}
@Override
public VerificationToken getVerificationToken(String verificationToken) {
return tokenRepository.findByToken(verificationToken);
}
@Override
public VerificationToken generateNewVerificationToken(String token) {
VerificationToken vToken = tokenRepository.findByToken(token);
vToken.updateToken(UUID.randomUUID()
.toString());
vToken = tokenRepository.save(vToken);
return vToken;
}
@Override
public void saveRegisteredUser(User user) {
userRepository.save(user);
}
@Override
public void createVerificationToken(User user, String token) {
VerificationToken myToken = new VerificationToken(token, user);
tokenRepository.save(myToken);
}
@Override
public User getUser(String verificationToken) {
User user = tokenRepository.findByToken(verificationToken).getUser();
return user;
}
@Override
public PasswordResetToken getPasswordResetToken(String token) {
return passwordTokenRepository.findByToken(token);
}
@Override
public User findUserByEmail(String email) {
return userRepository.findByEmail(email);
}
@Override
public void createPasswordResetTokenForUser(User user, String token) {
final PasswordResetToken myToken = new PasswordResetToken(token, user);
passwordTokenRepository.save(myToken);
}
public String validatePasswordResetToken(String token) {
final PasswordResetToken passToken = passwordTokenRepository.findByToken(token);
return !isTokenFound(passToken) ? "invalidToken"
: isTokenExpired(passToken) ? "expired"
: null;
}
@Override
public void changeUserPassword(User user, String password) {
user.setPassword(passwordEncoder.encode(password));
userRepository.save(user);
}
@Override
public Optional<User> getUserByPasswordResetToken(String token) {
return Optional.ofNullable(passwordTokenRepository.findByToken(token).getUser());
}
private boolean emailExists(String email) {
return userRepository.findByEmail(email) != null;
}
private boolean isTokenFound(PasswordResetToken passToken) {
return passToken != null;
}
private boolean isTokenExpired(PasswordResetToken passToken) {
final Calendar cal = Calendar.getInstance();
return passToken.getExpiryDate().before(cal.getTime());
}
}
OnRegistrationCompleteEvent.java, 註冊監聽事件
package top.yekongle.activate.event;
import java.util.Locale;
import lombok.*;
import org.springframework.context.ApplicationEvent;
import top.yekongle.activate.entity.User;
/**
* @Description:
* @Author: Yekongle
* @Date: 2020年6月6日
*/
@Getter
@Setter
public class OnRegistrationCompleteEvent extends ApplicationEvent {
private static final long serialVersionUID = 1L;
private String appUrl;
private Locale locale;
private User user;
public OnRegistrationCompleteEvent(User user, Locale locale, String appUrl) {
super(user);
this.user = user;
this.locale = locale;
this.appUrl = appUrl;
}
}
RegistrationListener.java, 註冊事件監聽, 發送激活郵件到用戶郵箱
package top.yekongle.activate.listener;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.MessageSource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import top.yekongle.activate.entity.User;
import top.yekongle.activate.event.OnRegistrationCompleteEvent;
import top.yekongle.activate.service.UserService;;
/**
* @Description:
* @Author: Yekongle
* @Date: 2020年6月6日
*/
@Slf4j
@Component
public class RegistrationListener implements ApplicationListener<OnRegistrationCompleteEvent> {
@Autowired
private UserService service;
@Autowired
private MessageSource messages;
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String emailFrom;
@Override
public void onApplicationEvent(OnRegistrationCompleteEvent event) {
this.confirmRegistration(event);
}
private void confirmRegistration(OnRegistrationCompleteEvent event) {
User user = event.getUser();
String token = UUID.randomUUID().toString();
service.createVerificationToken(user, token);
String recipientAddress = user.getEmail();
String subject = "Registration Confirmation";
String confirmationUrl = event.getAppUrl() + "/registrationConfirm.html?token=" + token;
log.info("confirmationUrl: {}" + confirmationUrl);
String message = messages.getMessage("message.regSucc", null, event.getLocale());
log.info("recipientAddress: {}", user.getEmail());
SimpleMailMessage email = new SimpleMailMessage();
email.setFrom(emailFrom);
email.setTo(recipientAddress);
email.setSubject(subject);
email.setText(message + "\r\n" + confirmationUrl);
mailSender.send(email);
}
}
運行演示
啓動項目
-
訪問 http://localhost:8080,會自動跳到登錄頁面,先點擊跳到註冊頁面
-
點擊註冊,將發送激活郵件
-
打開註冊郵箱查看激活郵件
-
在瀏覽器打開該鏈接,激活該賬戶
-
登錄該賬戶
-
如果需要重置密碼,則點擊重置密碼
7. 郵箱中查看重置密碼鏈接
-
在瀏覽器中打開重置密碼鏈接
-
點擊更改密碼
項目已上傳至 Github: https://github.com/yekongle/springboot-code-samples/tree/master/springboot-activate-account-sample , 希望對小夥伴們有幫助哦。
參考鏈接:
- https://v4.bootcss.com/docs/getting-started/introduction/
- https://github.com/Baeldung/spring-security-registration