用户登录时会让填写图片验证码,我们的产品需求是实现图片验证功能,验证码最多获取60次,超过60次后获取验证码功能会被锁定,锁定时长为10min,这里将实现的业务功能做一个简单总结;
创建项目 kapatcher,导入该功能的相关依赖:
<dependencies>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.7.20version>
dependency>
<dependency>
<groupId>com.github.pengglegroupId>
<artifactId>kaptchaartifactId>
<version>2.3.2version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-configuration-processorartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
dependencies>
SpringBoot配置文件:
spring:
redis:
host: localhost
port: 6379
messages:
basename: i18n/messages

@Configuration
public class KaptchaConfig {
private final static String CODE_LENGTH = "4";
private final static String SESSION_KEY = "handsome_yang";
@Bean
public DefaultKaptcha defaultKaptcha() {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
// 设置边框,合法值:yes , no
properties.setProperty("kaptcha.border", "yes");
// 设置边框颜色,合法值: r,g,b (and optional alpha) 或者 white,
properties.setProperty("kaptcha.border.color", "105,179,90");
// 设置字体颜色, r,g,b 或者 white,black,blue.
properties.setProperty("kaptcha.textproducer.font.color", "blue");
// 设置图片宽度
properties.setProperty("kaptcha.image.width", "118");
// 设置图片高度
properties.setProperty("kaptcha.image.height", "40");
// 设置字体尺寸
properties.setProperty("kaptcha.textproducer.font.size", "30");
// 设置session key
properties.setProperty("kaptcha.session.key", SESSION_KEY);
// 设置验证码长度
properties.setProperty("kaptcha.textproducer.char.length", CODE_LENGTH);
// 设置字体
properties.setProperty("kaptcha.textproducer.font.names", "楷体");
Config config = new Config(properties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置key的序列化方式
template.setKeySerializer(new StringRedisSerializer());
// 设置value的序列化方式
template.setValueSerializer(RedisSerializer.string());
template.afterPropertiesSet();
return template;
}
}
/**
* 验证码配置属性
*/
@Component
@ConfigurationProperties(prefix = "ngsoc.verify-code")
@Data
public class VerifyCodeProperties {
/**
* 验证码锁定时长,单位:秒
*/
private int lockTimeout = 10 * 60;
/**
* 验证码过期时长,单位:秒
*/
private int expireTimeout = 60;
/**
* 验证码获取次数阈值
*/
private int limitCount = 60;
}
public class VerifyCodeLimitException extends RuntimeException {
public VerifyCodeLimitException(String i18eCode){
super(I18nUtils.tryI18n(i18eCode));
}
public VerifyCodeLimitException(String i18eCode, Object... args) {
super(I18nUtils.tryI18n(i18eCode,args));
}
}
public class VerifyCodeWrongException extends RuntimeException {
public VerifyCodeWrongException(String i18eCode){
super(I18nUtils.tryI18n(i18eCode));
}
public VerifyCodeWrongException(String i18eCode, Object... args) {
super(I18nUtils.tryI18n(i18eCode,args));
}
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(VerifyCodeLimitException.class)
public ApiResponse<Object> handleVerifyCodeLimitException(VerifyCodeLimitException e) {
log.error(e.getMessage(), e);
return new ApiResponse<>(-1,"error",e.getMessage());
}
@ExceptionHandler(VerifyCodeWrongException.class)
public ApiResponse<Object> handleVerifyCodeWrongException(VerifyCodeWrongException e) {
log.error(e.getMessage(), e);
return new ApiResponse<>(-1,"error",e.getMessage());
}
}
// @Autowired 自动装配仅在托管类中有效(例如,注释为@ Component,@ Service或在应用程序上下文xml中定义)。
@Component
@Slf4j
public class I18nUtils {
// 如果当前bean不加@Component注解,则messageSource无法注入,始终为null
private static MessageSource messageSource;
@Autowired
public void setMessageSource(MessageSource messageSource) {
I18nUtils.messageSource = messageSource;
}
/**
* 解析code对应的信息进行返回,如果对应的code不能被解析则抛出异常NoSuchMessageException
*
* @param code 需要进行解析的code,对应资源文件中的一个属性名
* @param args 当对应code对应的信息不存在时需要返回的默认值
* @return 国际化翻译值
*/
public static String i18n(String code, Object... args) {
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
}
/**
* 解析code对应的信息进行返回,如果对应的code不能被解析则返回默认信息defaultMessage。
*
* @param code 需要进行解析的code,对应资源文件中的一个属性名
* @param defaultMessage 当对应code对应的信息不存在时需要返回的默认值
* @param args 需要用来替换code对应的信息中包含参数的内容,如:{0},{1,date},{2,time}
* @return 对应的Locale
*/
public static String i18nOrDefault(String code, String defaultMessage, Object... args) {
return messageSource.getMessage(code, args, defaultMessage, LocaleContextHolder.getLocale());
}
/**
* 因为i18n方法如果获取不到对应的键值,会抛异常NoSuchMessageException
* 本方法是对i18n方法的封装。当报错时并不抛出异常,而是返回source
*
* @param source 模板
* @param args 参数
* @return 返回I18n(正常结束)或者source(抛出异常)
* @see #i18n(String, Object...)
*/
public static String tryI18n( String source, @NonNull Object... args) {
String res;
try {
res = i18n(source, args);
} catch (Exception ignored) {
res = source;
}
return res;
}
}
i18n/messages.properties:
api.verifyCode.acquire.lock=acquire verification code after {0} s
redis.incr.verify.code.error=redis incr verify code error
api.verifyCode.wrong=verification code is wrong
i18n/messages_zh_CN.properties:
api.verifyCode.acquire.lock=请在 {0} 秒后获取验证码
redis.incr.verify.code.error=增加验证码访问次数异常
api.verifyCode.wrong=验证码错误
@Data
public class VerifyCodeVo {
/**
* 访问key
*/
private String codeKey;
/**
* 验证码
*/
private String verifyCode;
/**
* 验证码过期时间
*/
private int timeout;
public VerifyCodeVo(String codeKey, String verifyCode) {
this(codeKey, verifyCode, 0);
}
public VerifyCodeVo(String codeKey, String verifyCode, int timeout) {
this.codeKey = codeKey;
this.verifyCode = verifyCode;
this.timeout = timeout;
}
}
@RestController
@RequestMapping("/api/v2/auth")
@Validated
@Slf4j
public class AuthController {
@Autowired
private DefaultKaptcha defaultKaptcha;
@Autowired
private VerificationCodeService verificationCodeService;
/**
* 获取验证码
* @param request
* @param response
* @return
* @throws Exception
*/
@GetMapping(value = "/verifyCode")
public ResponseEntity<byte[]> acquireVerifyCode(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 获取远程客户端的真实IP
String clientIp ="1.1.1.1";
// 生成验证码
VerifyCodeVo verifyCodeVo = verificationCodeService.acquire(clientIp);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.IMAGE_JPEG);
headers.setCacheControl(CacheControl.noCache());
// 将验证码存储在客户端的cookie中
Cookie cookie = new Cookie("captcha", verifyCodeVo.getAccessKey());
cookie.setHttpOnly(true);
cookie.setMaxAge(verifyCodeVo.getTimeout());
cookie.setPath("/");
cookie.setSecure(true);
response.addCookie(cookie);
// 生成验证码图片
BufferedImage image = defaultKaptcha.createImage(verifyCodeVo.getVerifyCode());
ByteArrayOutputStream byteArrayOutputStream = null;
try {
byteArrayOutputStream = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", byteArrayOutputStream);
} catch (IOException e) {
log.error("响应验证码失败:" + e.getMessage());
}
return ResponseEntity.ok()
.headers(headers)
.body(byteArrayOutputStream.toByteArray());
}
}
@Service
@Slf4j
public class RedisVerificationCodeServiceImpl implements VerificationCodeService {
private static final String COUNT_KEY_PREFIX = "ngsoc:portal:imageCode:count:";
private static final String VALUE_KEY_PREFIX = "ngsoc:portal:imageCode:value:";
public static String generateCountKey(String clientId){
return COUNT_KEY_PREFIX + clientId;
}
public static String generateCodeKey(String accessKey){
return VALUE_KEY_PREFIX + accessKey;
}
@Autowired
private DefaultKaptcha defaultKaptcha;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private VerifyCodeProperties verifyCodeProperties;
/**
* 生成验证码并存储
*
* @param clientId 客户端ID
* @return VerifyCodeVo
*/
@Override
public VerifyCodeVo acquire(String clientId) {
// 累计clientId获取验证码次数
long count = this.incrCount(clientId);
log.info("client [{}] acquire verify code, times={}", clientId, count);
// 生成验证码
String code = defaultKaptcha.createText();
// 保存验证码到redis
String key = generateCodeKey( UUID.randomUUID().toString());
this.saveCode(key,code,verifyCodeProperties.getExpireTimeout());
return new VerifyCodeVo(key,code,verifyCodeProperties.getExpireTimeout());
}
}
验证码最多获取60次,超过60次后获取验证码功能会被锁定,锁定时长为10min
/***
* 累计clientId获取验证码次数
*
* @param clientId 远程客户端的真实IP
* @return 获取验证码的次数
*/
private long incrCount(String clientId) {
String key = generateCountKey(clientId);
String value = redisTemplate.opsForValue().get(key);
long count = Optional.ofNullable(value).map(Long::parseLong).orElse(0L);
//count超过验证码获取次数阈值,抛出异常,并导致expire时间后获取验证码
if(count > verifyCodeProperties.getLimitCount()){
Long expire = redisTemplate.getExpire(key, TimeUnit.SECONDS);
int expireTime = Optional.ofNullable(expire).orElse(0L).intValue();
throw new VerifyCodeLimitException("api.verifyCode.acquire.lock",expireTime);
}
//使用SessionCallBack接口,从而保证所有的命令都是通过同一个Redis的连接进行操作的
SessionCallback<Object> callback = new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
//监视key
operations.watch(key);
//开启事务
operations.multi();
operations.opsForValue().increment(key);
if (count == 0) {
//设置验证码的过期时长
operations.expire(key, verifyCodeProperties.getExpireTimeout(), TimeUnit.SECONDS);
} else if (count == verifyCodeProperties.getLockTimeout()) {
// 如果count达到验证码获取次数阈值,设置验证码的锁定时长
operations.expire(key, verifyCodeProperties.getLockTimeout(), TimeUnit.SECONDS);
}
// 执行事务
List<Object> resultList = operations.exec();
Long incr = Optional.ofNullable(resultList)
.filter(result -> result.size() > 0)
.map(result -> Long.parseLong(result.get(0).toString())).orElse(0L);
return incr;
}
};
long incr;
try {
incr = (Long)redisTemplate.execute(callback);
}catch (RedisException e){
log.error("redis incr verify code error: ", e);
// 回滚事务
redisTemplate.discard();
throw new VerifyCodeLimitException("redis.incr.verify.code.error");
}finally {
// 取消对key的监视
redisTemplate.unwatch();
}
if(incr == 0){
throw new VerifyCodeLimitException("redis.incr.verify.code.error");
}else if(incr> verifyCodeProperties.getLimitCount()){
throw new VerifyCodeLimitException("redis.incr.verify.code.error");
}
return incr;
}
/**
* 存储验证码
*
* @param key redis的 key
* @param code 验证码
* @param expireTimeout 验证码过期时间
*/
private void saveCode(String key, String code, int expireTimeout) {
this.redisTemplate.opsForValue().set(key,code,expireTimeout,TimeUnit.SECONDS);
}
启动项目并访问获取验证码接口:http://localhost:8080/api/v2/auth/verifyCode

查看redis中存储的数据:

@Data
public class LoginRequestVo {
/**
* 用户名
*/
private String username;
/**
* 密码 (密文)
*/
private String password;
/**
* 验证码
*/
private String verifyCode;
}
@RestController
@RequestMapping("/api/v2/auth")
@Validated
@Slf4j
public class AuthController {
@Autowired
private DefaultKaptcha defaultKaptcha;
@Autowired
private VerificationCodeService verificationCodeService;
@PostMapping(value = "/login")
public ApiResponse<Object> login(
LoginRequestVo requestVo,
@CookieValue(value = "captcha", required = false) String accessKey,
HttpServletRequest httpRequest,
HttpServletResponse httpServletResponse) {
String clientIp ="1.1.1.1";
if (!verificationCodeService.verify(clientIp, new VerifyCodeVo(accessKey, requestVo.getVerifyCode()))) {
throw new VerifyCodeWrongException("api.verifyCode.wrong");
}
// 省略登录过程....
return new ApiResponse<>(0,"success");
}
}
@Service
@Slf4j
public class RedisVerificationCodeServiceImpl implements VerificationCodeService {
public static String generateCodeKey(String accessKey){
return VALUE_KEY_PREFIX + accessKey;
}
/**
* 校验验证码
*
* @param clientId 客户端ID
* @param verifyCodeVo 验证码
* @return 校验成功与否
*/
@Override
public boolean verify(String clientId, VerifyCodeVo verifyCodeVo) {
String cacheCode = this.findCode(verifyCodeVo.getCodeKey());
return cacheCode.equalsIgnoreCase(verifyCodeVo.getVerifyCode());
}
/**
* 获取验证码
* @param codeKey 访问key
* @return 验证码
*/
private String findCode(String codeKey) {
String code = this.redisTemplate.opsForValue().get(codeKey);
if(Objects.isNull(code)){
throw new VerifyCodeWrongException("api.verifyCode.wrong");
}
// 获取验证码后删除redis的key
this.redisTemplate.delete(codeKey);
return code;
}
}
首先获取一个验证码:

复制响应的Cookie并放在登录接口的请求头中:

登录接口的请求头中放入Cookie信息:

登录接口的请求体信息:
