注册-通过邮件激活新账号

1.概述

注册确认机制强制用户回应注册成功后发送的“确认注册”邮件。

根据这个逻辑,一个新注册的用户将不能登录系统,直到完成本过程。

2.验证token

我们将使用一个简单的验证token作为关键工件,通过它来验证用户。

2.1. VerificationToken 实体

VerificationToken 实体必须满足以下条件:

  1. 必须链接回用户(通过一个单向关系)
  2. 注册后被正确创建
  3. 24小时内过期
  4. 拥有唯一随机值

2、3是注册逻辑必需的部分,其他两个在简单的VerificationToken 实体中实现,如例子2.1所示

例子2.1.

@Entity
public class VerificationToken {
    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;
    
    private Date calculateExpiryDate(int expiryTimeInMinutes) {
        Calendar cal = Calendar.getInstance();
        cal.setTime(new Timestamp(cal.getTime().getTime()));
        cal.add(Calendar.MINUTE, expiryTimeInMinutes);
        return new Date(cal.getTime().getTime());
    }
     
    // standard constructors, getters and setters
}

注意 User的nullable = false 保证VerificationToken<->User关系的数据完整性和一致性

2.2. User添加enabled字段

首先,当注册用户时,enabled字段将会设置为false。在账号认证过程-如果成功,它将会修改为true。

我们开始把字段添加到User实体中:

public class User {
    ...
    @Column(name = "enabled")
    private boolean enabled;
     
    public User() {
        super();
        this.enabled=false;
    }
    ...
}

注意我们还把字段值默认设置为false。

3.账号注册过程

让我们向用户注册用例添加另外两个业务逻辑

  1. 创建并持久化用户的VerificationToken 
  2. 向外发送账号的确认邮件信息-包括带有VerificationToken值的确认链接

3.1. 使用Spring Event 创建Token并发送验证邮件

这两个额外的逻辑不应该由controller直接去执行,因为它们是“附带的”后端任务。

Controller将发布一个Spring ApplicationEvent 事件,触发这些任务的执行。这如注入ApplicationEventPublisher 那样简单,然后用它来发布注册完成。

例子 3.1. 展示了这个简单的逻辑:

例子3.1.

@Autowired
ApplicationEventPublisher eventPublisher
 
@RequestMapping(value = "/user/registration", method = RequestMethod.POST)
public ModelAndView registerUserAccount(
  @ModelAttribute("user") @Valid UserDto accountDto, 
  BindingResult result, 
  WebRequest request, 
  Errors errors) {
  
    if (result.hasErrors()) {
        return new ModelAndView("registration", "user", accountDto);
    }
     
    User registered = createUserAccount(accountDto);
    if (registered == null) {
        result.rejectValue("email", "message.regError");
    }
    try {
        String appUrl = request.getContextPath();
        eventPublisher.publishEvent(new OnRegistrationCompleteEvent
          (registered, request.getLocale(), appUrl));
    } catch (Exception me) {
        return new ModelAndView("emailError", "user", accountDto);
    }
    return new ModelAndView("successRegister", "user", accountDto);
}

值得注意的另外一件事是用try catch块环绕事件的发布。这块代码将显示一个错误页面,无论何时,当事件发布后执行的逻辑发生异常,这种情况下都会发送一封邮件。

3.2. 事件和监听器

现在,让我们看一下,我们的controller向外发送新的OnRegistrationCompleteEvent 实际的实现,还有处理它的监听器:

 例子3.2.1. – The OnRegistrationCompleteEvent

public class OnRegistrationCompleteEvent extends ApplicationEvent {
    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;
    }
     
    // standard getters and setters
}

例子3.2.2. 处理OnRegistrationCompleteEvent事件

@Component
public class RegistrationListener implements
  ApplicationListener<OnRegistrationCompleteEvent> {
  
    @Autowired
    private IUserService service;
  
    @Autowired
    private MessageSource messages;
  
    @Autowired
    private JavaMailSender mailSender;
 
    @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() + "/regitrationConfirm.html?token=" + token;
        String message = messages.getMessage("message.regSucc", null, event.getLocale());
         
        SimpleMailMessage email = new SimpleMailMessage();
        email.setTo(recipientAddress);
        email.setSubject(subject);
        email.setText(message + " rn" + "http://localhost:8080" + confirmationUrl);
        mailSender.send(email);
    }
}

这里,confirmRegistration 方法将会接收OnRegistrationCompleteEvent,提取所有必要的User信息,创建一个验证token,持久化,并且把它作为“确认注册”链接的参数发送。

像上述提到的,任何JavaMailSender 抛出的javax.mail.AuthenticationFailedException 异常,都会被controller处理

3.3. 处理验证Token参数

当用户接收到“确认注册”链接时,他们应该点击它。

一旦他们这样做,controller会从GET请求结果里,提取token属性值,并用它来启用用户。

让我们在例子3.3.1看一下这个过程。

例子3.3.1. – RegistrationController  处理注册确认

@Autowired
private IUserService service;
 
@RequestMapping(value = "/regitrationConfirm", method = RequestMethod.GET)
public String confirmRegistration
  (WebRequest request, Model model, @RequestParam("token") String token) {
  
    Locale locale = request.getLocale();
     
    VerificationToken verificationToken = service.getVerificationToken(token);
    if (verificationToken == null) {
        String message = messages.getMessage("auth.message.invalidToken", null, locale);
        model.addAttribute("message", message);
        return "redirect:/badUser.html?lang=" + locale.getLanguage();
    }
     
    User user = verificationToken.getUser();
    Calendar cal = Calendar.getInstance();
    if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) {
        String messageValue = messages.getMessage("auth.message.expired", null, locale)
        model.addAttribute("message", messageValue);
        return "redirect:/badUser.html?lang=" + locale.getLanguage();
    } 
     
    user.setEnabled(true); 
    service.saveRegisteredUser(user); 
    return "redirect:/login.html?lang=" + request.getLocale().getLanguage(); 
}

用户将会直接跳转到一个带有相应信息的错误页面,如果:

  1. 因为某些原因VerificationToken不存在
  2. VerificationToken过期

查看例子3.3.2.看下错误页面

例子3.3.2. – The badUser.html

<html>
<body>
    <h1 th:text="${param.message[0]}>Error Message</h1>
    <a th:href="@{/registration.html}"
      th:text="#{label.form.loginSignUp}">signup</a>
</body>
</html>

如果没发现错误,就启用用户。

这里在处理检查VerificationToken 和过期的场景,有两个改进的机会:

  1. 我们可以在后台使用Cron Job检查token的有效期
  2. 一旦token过期,我们可以给该用户重新获取token的机会

4.在登录过程,添加账号激活检查

如果用户已经启用,我们需要添加检查代码;

让我们来看一下,例子4.1所示MyUserDetailsService的loadUserByUsername 方法。

例子4.1.

@Autowired
UserRepository userRepository;
 
public UserDetails loadUserByUsername(String email) 
  throws UsernameNotFoundException {
  
    boolean enabled = true;
    boolean accountNonExpired = true;
    boolean credentialsNonExpired = true;
    boolean accountNonLocked = true;
    try {
        User user = userRepository.findByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException(
              "No user found with username: " + email);
        }
         
        return new org.springframework.security.core.userdetails.User(
          user.getEmail(), 
          user.getPassword().toLowerCase(), 
          user.isEnabled(), 
          accountNonExpired, 
          credentialsNonExpired, 
          accountNonLocked, 
          getAuthorities(user.getRole()));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

如我们所见,现在MyUserDetailsService 并没有使用user的enabled标志-并且它只允许启用认证的用户。

现在,我们将一个AuthenticationFailureHandler 添加到自定义的来自MyUserDetailsService的异常信息,我们的CustomAuthenticationFailureHandler 如例子4.2所示

例子4.2. – CustomAuthenticationFailureHandler:

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
 
    @Autowired
    private MessageSource messages;
 
    @Autowired
    private LocaleResolver localeResolver;
 
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, 
      HttpServletResponse response, AuthenticationException exception)
      throws IOException, ServletException {
        setDefaultFailureUrl("/login.html?error=true");
 
        super.onAuthenticationFailure(request, response, exception);
 
        Locale locale = localeResolver.resolveLocale(request);
 
        String errorMessage = messages.getMessage("message.badCredentials", null, locale);
 
        if (exception.getMessage().equalsIgnoreCase("User is disabled")) {
            errorMessage = messages.getMessage("auth.message.disabled", null, locale);
        } else if (exception.getMessage().equalsIgnoreCase("User account has expired")) {
            errorMessage = messages.getMessage("auth.message.expired", null, locale);
        }
 
        request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, errorMessage);
    }
}

我们还需要编辑login.html页面显示错误的信息。

例子4.3. – 在login.html显示错误信息:

<div th:if="${param.error != null}"
  th:text="${session[SPRING_SECURITY_LAST_EXCEPTION]}">error</div>

5.适配持久化层

现在让我们提供一个涉及验证token以及用户相关操作的实际的实现

我们将讨论:

  1. 一个新的VerificationTokenRepository
  2. IUserInterface 接口添加新的方法,并且实现它所需的增删改查的操作。

 

例子5.1-5.3展示新的接口和实现

例子5.1. – The VerificationTokenRepository

public interface VerificationTokenRepository 
  extends JpaRepository<VerificationToken, Long> {
 
    VerificationToken findByToken(String token);
 
    VerificationToken findByUser(User user);
}

例子5.2. – The IUserService Interface

public interface IUserService {
     
    User registerNewUserAccount(UserDto accountDto) 
      throws EmailExistsException;
 
    User getUser(String verificationToken);
 
    void saveRegisteredUser(User user);
 
    void createVerificationToken(User user, String token);
 
    VerificationToken getVerificationToken(String VerificationToken);
}

例子5.3. The UserService

@Service
@Transactional
public class UserService implements IUserService {
    @Autowired
    private UserRepository repository;
 
    @Autowired
    private VerificationTokenRepository tokenRepository;
 
    @Override
    public User registerNewUserAccount(UserDto accountDto) 
      throws EmailExistsException {
         
        if (emailExist(accountDto.getEmail())) {
            throw new EmailExistsException(
              "There is an account with that email adress: "
              + accountDto.getEmail());
        }
         
        User user = new User();
        user.setFirstName(accountDto.getFirstName());
        user.setLastName(accountDto.getLastName());
        user.setPassword(accountDto.getPassword());
        user.setEmail(accountDto.getEmail());
        user.setRole(new Role(Integer.valueOf(1), user));
        return repository.save(user);
    }
 
    private boolean emailExist(String email) {
        User user = repository.findByEmail(email);
        if (user != null) {
            return true;
        }
        return false;
    }
     
    @Override
    public User getUser(String verificationToken) {
        User user = tokenRepository.findByToken(verificationToken).getUser();
        return user;
    }
     
    @Override
    public VerificationToken getVerificationToken(String VerificationToken) {
        return tokenRepository.findByToken(VerificationToken);
    }
     
    @Override
    public void saveRegisteredUser(User user) {
        repository.save(user);
    }
     
    @Override
    public void createVerificationToken(User user, String token) {
        VerificationToken myToken = new VerificationToken(token, user);
        tokenRepository.save(myToken);
    }
}

6. 总结

在本文中,我们扩展了注册过程,包括基于邮件的账号激活过程。

账号激活逻辑需要通过邮件,发送一个验证token给用户。因此他们可以回发给controller,核实他们的身份。

原文地址

 

转载于:https://my.oschina.net/benz001/blog/3008747

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值