基于Angular与Spring Boot的用户邮箱/手机号OTP验证方案问询
Angular + Spring Boot 注册邮箱/手机号OTP验证最优实现方案
核心设计思路
- 缓存存储OTP:用Redis存储一次性密码,设置5分钟过期时间,避免占用数据库资源,自动失效无需手动清理
- 异步发送消息:邮件/短信发送采用异步处理,避免阻塞注册请求,提升用户体验
- 独立校验机制:邮箱和手机号的OTP分开生成、存储与校验,互不影响
- 防刷限制:限制同一邮箱/手机号的OTP发送频率(如1分钟最多3次),防止恶意攻击
后端(Spring Boot)具体实现
1. 依赖准备
在pom.xml中添加必要依赖:
<!-- Redis缓存 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 邮件发送 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency> <!-- 异步支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 可选:Spring Retry处理发送失败重试 --> <dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency>
2. OTP生成工具
实现随机6位数字OTP生成:
import java.security.SecureRandom; @Component public class OtpGenerator { public String generate6DigitOtp() { SecureRandom random = new SecureRandom(); // 生成100000-999999之间的随机数 int otp = 100000 + random.nextInt(900000); return String.valueOf(otp); } }
3. Redis缓存操作
封装OTP的存储与校验逻辑:
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; @Component public class OtpCacheService { private final StringRedisTemplate redisTemplate; private static final int OTP_EXPIRE_MINUTES = 5; public OtpCacheService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } // 存储邮箱OTP public void saveEmailOtp(String email, String otp) { String key = String.format("otp:email:%s", email); redisTemplate.opsForValue().set(key, otp, OTP_EXPIRE_MINUTES, TimeUnit.MINUTES); } // 存储手机号OTP public void savePhoneOtp(String phone, String otp) { String key = String.format("otp:phone:%s", phone); redisTemplate.opsForValue().set(key, otp, OTP_EXPIRE_MINUTES, TimeUnit.MINUTES); } // 校验邮箱OTP,校验成功后删除缓存防止重复使用 public boolean validateEmailOtp(String email, String inputOtp) { String key = String.format("otp:email:%s", email); String storedOtp = redisTemplate.opsForValue().get(key); if (inputOtp.equals(storedOtp)) { redisTemplate.delete(key); return true; } return false; } // 校验手机号OTP public boolean validatePhoneOtp(String phone, String inputOtp) { String key = String.format("otp:phone:%s", phone); String storedOtp = redisTemplate.opsForValue().get(key); if (inputOtp.equals(storedOtp)) { redisTemplate.delete(key); return true; } return false; } }
4. 消息发送服务
实现异步邮件和短信发送(短信需对接第三方服务如阿里云/腾讯云):
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @Service public class MessageService { private final JavaMailSender mailSender; // 注入短信服务客户端(如阿里云SmsClient) public MessageService(JavaMailSender mailSender) { this.mailSender = mailSender; } @Async public void sendEmailOtp(String toEmail, String otp) { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(toEmail); message.setSubject("注册验证码"); message.setText(String.format("你的注册验证码是:%s,5分钟内有效,请尽快完成验证。", otp)); mailSender.send(message); } @Async public void sendSmsOtp(String phone, String otp) { // 调用第三方短信服务API发送验证码 // 示例:aliyunSmsClient.sendSms(phone, "你的注册验证码是:" + otp); } }
5. 注册接口实现
分两步接口:初始化注册(发送OTP)、验证OTP并创建用户:
import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/auth") public class AuthController { private final OtpGenerator otpGenerator; private final OtpCacheService otpCacheService; private final MessageService messageService; private final UserRepository userRepository; // 假设已定义用户Repository public AuthController(OtpGenerator otpGenerator, OtpCacheService otpCacheService, MessageService messageService, UserRepository userRepository) { this.otpGenerator = otpGenerator; this.otpCacheService = otpCacheService; this.messageService = messageService; this.userRepository = userRepository; } // 第一步:提交注册信息,发送OTP @PostMapping("/register/init") public ResponseEntity<String> initRegister(@RequestBody RegisterInitRequest request) { // 生成邮箱和手机号OTP String emailOtp = otpGenerator.generate6DigitOtp(); String phoneOtp = otpGenerator.generate6DigitOtp(); // 存储OTP到Redis otpCacheService.saveEmailOtp(request.getEmail(), emailOtp); otpCacheService.savePhoneOtp(request.getPhone(), phoneOtp); // 异步发送OTP messageService.sendEmailOtp(request.getEmail(), emailOtp); messageService.sendSmsOtp(request.getPhone(), phoneOtp); return ResponseEntity.ok("验证码已发送,请查收"); } // 第二步:验证OTP并创建用户 @PostMapping("/register/verify") public ResponseEntity<String> verifyRegister(@RequestBody RegisterVerifyRequest request) { // 校验两个OTP boolean emailValid = otpCacheService.validateEmailOtp(request.getEmail(), request.getEmailOtp()); boolean phoneValid = otpCacheService.validatePhoneOtp(request.getPhone(), request.getPhoneOtp()); if (!emailValid || !phoneValid) { return ResponseEntity.badRequest().body("验证码无效或已过期"); } // 创建用户(需加密密码) User user = new User(); user.setUsername(request.getUsername()); user.setEmail(request.getEmail()); user.setPhone(request.getPhone()); // 密码加密:user.setPassword(passwordEncoder.encode(request.getPassword())); userRepository.save(user); return ResponseEntity.ok("注册成功"); } // 可选:重发OTP接口 @PostMapping("/register/resend-otp") public ResponseEntity<String> resendOtp(@RequestBody ResendOtpRequest request) { // 此处可添加发送频率限制逻辑 String emailOtp = otpGenerator.generate6DigitOtp(); String phoneOtp = otpGenerator.generate6DigitOtp(); otpCacheService.saveEmailOtp(request.getEmail(), emailOtp); otpCacheService.savePhoneOtp(request.getPhone(), phoneOtp); messageService.sendEmailOtp(request.getEmail(), emailOtp); messageService.sendSmsOtp(request.getPhone(), phoneOtp); return ResponseEntity.ok("验证码已重新发送"); } // 定义请求DTO public static class RegisterInitRequest { private String email; private String phone; private String username; private String password; // getter/setter } public static class RegisterVerifyRequest { private String email; private String emailOtp; private String phone; private String phoneOtp; private String username; private String password; // getter/setter } public static class ResendOtpRequest { private String email; private String phone; // getter/setter } }
前端(Angular)具体实现
1. 服务封装
创建AuthService处理后端接口调用:
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AuthService { private baseUrl = '/api/auth'; constructor(private http: HttpClient) {} // 初始化注册,发送OTP initRegister(userData: {email: string, phone: string, username: string, password: string}): Observable<any> { return this.http.post(`${this.baseUrl}/register/init`, userData); } // 验证OTP并完成注册 verifyRegister(verifyData: {email: string, emailOtp: string, phone: string, phoneOtp: string, username: string, password: string}): Observable<any> { return this.http.post(`${this.baseUrl}/register/verify`, verifyData); } // 重发OTP resendOtp(data: {email: string, phone: string}): Observable<any> { return this.http.post(`${this.baseUrl}/register/resend-otp`, data); } }
2. 注册页面组件
实现两步式注册表单,包含倒计时和错误提示:
import { Component, OnDestroy } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { AuthService } from './auth.service'; @Component({ selector: 'app-register', templateUrl: './register.component.html' }) export class RegisterComponent implements OnDestroy { currentStep = 1; registerForm: FormGroup; otpForm: FormGroup; countdown = 60; countdownTimer: any; constructor(private fb: FormBuilder, private authService: AuthService) { // 第一步表单:注册基本信息 this.registerForm = this.fb.group({ email: ['', [Validators.required, Validators.email]], phone: ['', [Validators.required, Validators.pattern('^1[3-9]\\d{9}$')]], username: ['', Validators.required], password: ['', [Validators.required, Validators.minLength(6)]] }); // 第二步表单:OTP输入 this.otpForm = this.fb.group({ emailOtp: ['', [Validators.required, Validators.pattern('^\\d{6}$')]], phoneOtp: ['', [Validators.required, Validators.pattern('^\\d{6}$')]] }); } // 提交第一步,发送OTP submitRegisterInit() { if (this.registerForm.invalid) return; this.authService.initRegister(this.registerForm.value).subscribe({ next: () => { this.currentStep = 2; this.startCountdown(); }, error: (err) => { alert(err.error || '发送验证码失败,请稍后重试'); } }); } // 提交第二步,验证OTP submitRegisterVerify() { if (this.otpForm.invalid) return; const verifyData = { ...this.registerForm.value, ...this.otpForm.value }; this.authService.verifyRegister(verifyData).subscribe({ next: () => { alert('注册成功!'); // 跳转到登录页 // this.router.navigate(['/login']); }, error: (err) => { alert(err.error || '验证码无效或已过期'); } }); } // 重发OTP resendOtp() { const { email, phone } = this.registerForm.value; this.authService.resendOtp({ email, phone }).subscribe({ next: () => { this.countdown = 60; this.startCountdown(); }, error: (err) => { alert(err.error || '重发验证码失败'); } }); } // 启动倒计时 private startCountdown() { if (this.countdownTimer) clearInterval(this.countdownTimer); this.countdownTimer = setInterval(() => { this.countdown--; if (this.countdown <= 0) { clearInterval(this.countdownTimer); } }, 1000); } ngOnDestroy() { if (this.countdownTimer) clearInterval(this.countdownTimer); } }
3. 页面模板示例
<div class="register-container"> <!-- 第一步:注册信息输入 --> <div *ngIf="currentStep === 1"> <h2>注册账号</h2> <form [formGroup]="registerForm" (ngSubmit)="submitRegisterInit()"> <div> <label>邮箱:</label> <input type="email" formControlName="email"> <span *ngIf="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">请输入有效邮箱</span> </div> <div> <label>手机号:</label> <input type="text" formControlName="phone"> <span *ngIf="registerForm.get('phone')?.invalid && registerForm.get('phone')?.touched">请输入有效手机号</span> </div> <div> <label>用户名:</label> <input type="text" formControlName="username"> <span *ngIf="registerForm.get('username')?.invalid && registerForm.get('username')?.touched">用户名不能为空</span> </div> <div> <label>密码:</label> <input type="password" formControlName="password"> <span *ngIf="registerForm.get('password')?.invalid && registerForm.get('password')?.touched">密码至少6位</span> </div> <button type="submit" [disabled]="registerForm.invalid">发送验证码</button> </form> </div> <!-- 第二步:OTP验证 --> <div *ngIf="currentStep === 2"> <h2>验证验证码</h2> <form [formGroup]="otpForm" (ngSubmit)="submitRegisterVerify()"> <div> <label>邮箱验证码:</label> <input type="text" formControlName="emailOtp"> <span *ngIf="otpForm.get('emailOtp')?.invalid && otpForm.get('emailOtp')?.touched">请输入6位数字验证码</span> </div> <div> <label>手机号验证码:</label> <input type="text" formControlName="phoneOtp"> <span *ngIf="otpForm.get('phoneOtp')?.invalid && otpForm.get('phoneOtp')?.touched">请输入6位数字验证码</span> </div> <button type="submit" [disabled]="otpForm.invalid">完成注册</button> <button type="button" (click)="resendOtp()" [disabled]="countdown > 0"> {{ countdown > 0 ? `${countdown}秒后重发` : '重发验证码' }} </button> </form> </div> </div>
优化建议
- 密码加密:后端存储用户密码时,使用BCrypt等加密算法加密,避免明文存储
- 发送频率限制:在
resendOtp接口中,用Redis记录同一邮箱/手机号的发送次数,如1分钟内最多允许3次请求 - 重试机制:对邮件/短信发送失败的情况,使用Spring Retry进行重试,提高送达率
- 日志监控:记录OTP发送、校验的日志,方便排查发送失败或校验异常问题
- 前端安全:添加验证码图形验证,防止机器人恶意请求OTP
内容的提问来源于stack exchange,提问作者Harshal Chopade
相关产品推荐
相关产品推荐

