随着近几年技术的发展,人们对于系统安全性和用户体验的要求越来越高,大多数网站系统都逐渐采用行为验证码来代替图片验证码。GitEgg-Cloud集成了开源行为验证码组件和图片验证码,并在系统中添加可配置项来选择具体使用哪种验证码。
- AJ-Captcha:行为验证码
- EasyCaptcha: 图片验证码
1、在我们的gitegg-platform-bom工程中增加验证码的包依赖
<!-- AJ-Captcha滑动验证码 --> <captcha.version>1.2.7</captcha.version> <!-- Easy-Captcha图形验证码 --> <easy.captcha.version>1.6.2</easy.captcha.version> <!-- captcha 滑动验证码--> <dependency> <groupId>com.github.anji-plus</groupId> <artifactId>captcha-spring-boot-starter</artifactId> <version>${captcha.version}</version> </dependency> <!-- easy-captcha 图形验证码--> <dependency> <groupId>com.github.whvcse</groupId> <artifactId>easy-captcha</artifactId> <version>${easy.captcha.version}</version> </dependency>
2、新建gitegg-platform-captcha工程,用于配置及自定义方法,行为验证码用到缓存是需要自定义实现CaptchaCacheService,自定义类CaptchaCacheServiceRedisImpl:
public class CaptchaCacheServiceRedisImpl implements CaptchaCacheService { @Override public String type() { return "redis"; } @Autowired private StringRedisTemplate stringRedisTemplate; @Override public void set(String key, String value, long expiresInSeconds) { stringRedisTemplate.opsForValue().set(key, value, expiresInSeconds, TimeUnit.SECONDS); } @Override public boolean exists(String key) { return stringRedisTemplate.hasKey(key); } @Override public void delete(String key) { stringRedisTemplate.delete(key); } @Override public String get(String key) { return stringRedisTemplate.opsForValue().get(key); } }
3、在gitegg-platform-captcha的resources目录新建META-INF.services文件夹,参考resource/META-INF/services中的写法。
com.gitegg.platform.captcha.service.impl.CaptchaCacheServiceRedisImpl
4、在GitEgg-Cloud下的gitegg-oauth中增加CaptchaTokenGranter自定义验证码令牌授权处理类
/** * 验证码模式 */ public class CaptchaTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE = "captcha"; private final AuthenticationManager authenticationManager; private RedisTemplate redisTemplate; private CaptchaService captchaService; private String captchaType; public CaptchaTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, RedisTemplate redisTemplate, CaptchaService captchaService, String captchaType) { this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE); this.redisTemplate = redisTemplate; this.captchaService = captchaService; this.captchaType = captchaType; } protected CaptchaTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, String grantType) { super(tokenServices, clientDetailsService, requestFactory, grantType); this.authenticationManager = authenticationManager; } @Override protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { Map<String, String> parameters = new LinkedHashMap<>(tokenRequest.getRequestParameters()); // 获取验证码类型 String captchaType = parameters.get(CaptchaConstant.CAPTCHA_TYPE); // 判断传入的验证码类型和系统配置的是否一致 if (!StringUtils.isEmpty(captchaType) && !captchaType.equals(this.captchaType)) { throw new UserDeniedAuthorizationException(ResultCodeEnum.INVALID_CAPTCHA_TYPE.getMsg()); } if (CaptchaConstant.IMAGE_CAPTCHA.equalsIgnoreCase(captchaType)) { // 图片验证码验证 String captchaKey = parameters.get(CaptchaConstant.CAPTCHA_KEY); String captchaCode = parameters.get(CaptchaConstant.CAPTCHA_CODE); // 获取验证码 String redisCode = (String)redisTemplate.opsForValue().get(CaptchaConstant.IMAGE_CAPTCHA_KEY + captchaKey); // 判断验证码 if (captchaCode == null || !captchaCode.equalsIgnoreCase(redisCode)) { throw new UserDeniedAuthorizationException(ResultCodeEnum.INVALID_CAPTCHA.getMsg()); } } else { // 滑动验证码验证 String captchaVerification = parameters.get(CaptchaConstant.CAPTCHA_VERIFICATION); String slidingCaptchaType = parameters.get(CaptchaConstant.SLIDING_CAPTCHA_TYPE); CaptchaVO captchaVO = new CaptchaVO(); captchaVO.setCaptchaVerification(captchaVerification); captchaVO.setCaptchaType(slidingCaptchaType); ResponseModel responseModel = captchaService.verification(captchaVO); if (null == responseModel || !RepCodeEnum.SUCCESS.getCode().equals(responseModel.getRepCode())) { throw new UserDeniedAuthorizationException(ResultCodeEnum.INVALID_CAPTCHA.getMsg()); } } String username = parameters.get(TokenConstant.USER_NAME); String password = parameters.get(TokenConstant.PASSWORD); // Protect from downstream leaks of password parameters.remove(TokenConstant.PASSWORD); Authentication userAuth = new UsernamePasswordAuthenticationToken(username, password); ((AbstractAuthenticationToken)userAuth).setDetails(parameters); try { userAuth = authenticationManager.authenticate(userAuth); } catch (AccountStatusException | BadCredentialsException ase) { // covers expired, locked, disabled cases (mentioned in section 5.2, draft 31) throw new InvalidGrantException(ase.getMessage()); } // If the username/password are wrong the spec says we should send 400/invalid grant if (userAuth == null || !userAuth.isAuthenticated()) { throw new InvalidGrantException("Could not authenticate user: " + username); } OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest); return new OAuth2Authentication(storedOAuth2Request, userAuth); } }
5、gitegg-oauth中GitEggOAuthController新增获取验证码的方法
@Value("${captcha.type}") private String captchaType; @ApiOperation("获取系统配置的验证码类型") @GetMapping("/captcha/type") public Result captchaType() { return Result.data(captchaType); } @ApiOperation("生成滑动验证码") @PostMapping("/captcha") public Result captcha(@RequestBody CaptchaVO captchaVO) { ResponseModel responseModel = captchaService.get(captchaVO); return Result.data(responseModel); } @ApiOperation("滑动验证码验证") @PostMapping("/captcha/check") public Result captchaCheck(@RequestBody CaptchaVO captchaVO) { ResponseModel responseModel = captchaService.check(captchaVO); return Result.data(responseModel); } @ApiOperation("生成图片验证码") @RequestMapping("/captcha/image") public Result captchaImage() { SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 5); String captchaCode = specCaptcha.text().toLowerCase(); String captchaKey = UUID.randomUUID().toString(); // 存入redis并设置过期时间为5分钟 redisTemplate.opsForValue().set(CaptchaConstant.IMAGE_CAPTCHA_KEY + captchaKey, captchaCode, GitEggConstant.Number.FIVE, TimeUnit.MINUTES); ImageCaptcha imageCaptcha = new ImageCaptcha(); imageCaptcha.setCaptchaKey(captchaKey); imageCaptcha.setCaptchaImage(specCaptcha.toBase64()); // 将key和base64返回给前端 return Result.data(imageCaptcha); }
6、将滑动验证码提供的前端页面verifition目录copy到我们前端工程的compoonents目录,修改Login.vue,增加验证码
<a-row :gutter="0" v-if="loginCaptchaType === 'image' && grantType !== 'password'"> <a-col :span="14"> <a-form-item> <a-input v-decorator="['captchaCode', validatorRules.captchaCode]" size="large" type="text" :placeholder="$t('user.verification-code.required')"> <a-icon v-if="inputCodeContent == verifiedCode" slot="prefix" type="safety-certificate" :style="{ fontSize: '20px', color: '#1890ff' }" /> <a-icon v-else slot="prefix" type="safety-certificate" :style="{ fontSize: '20px', color: '#1890ff' }" /> </a-input> </a-form-item> </a-col> <a-col :span="10"> <img :src="captchaImage" class="v-code-img" @click="refreshImageCode"> </a-col> </a-row>
<Verify @success="verifySuccess" :mode="'pop'" :captchaType="slidingCaptchaType" :imgSize="{ width: '330px', height: '155px' }" ref="verify"></Verify>
grantType: 'password', loginCaptchaType: 'sliding', slidingCaptchaType: 'blockPuzzle', loginErrorMsg: '用户名或密码错误', captchaKey: '', captchaCode: '', captchaImage: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAICRAEAOw==', inputCodeContent: '', inputCodeNull: true
methods: { ...mapActions(['Login', 'Logout']), // handler handleUsernameOrEmail (rule, value, callback) { const { state } = this const regex = /^([a-zA-Z0-9_-])+@([a-zA-Z0-9_-])+((\.[a-zA-Z0-9_-]{2,3}){1,2})$/ if (regex.test(value)) { state.loginType = 0 } else { state.loginType = 1 } callback() }, // 滑动验证码二次校验并提交登录 verifySuccess (params) { // params 返回的二次验证参数, 和登录参数一起回传给登录接口,方便后台进行二次验证 const { form: { validateFields }, state, customActiveKey, Login } = this state.loginBtn = true const validateFieldsKey = customActiveKey === 'tab_account' ? ['username', 'password', 'captchaCode', 'captchaKey'] : ['phoneNumber', 'captcha', 'captchaCode', 'captchaKey'] validateFields(validateFieldsKey, { force: true }, (err, values) => { if (!err) { const loginParams = { ...values } delete loginParams.username loginParams[!state.loginType ? 'email' : 'username'] = values.username loginParams.client_id = process.env.VUE_APP_CLIENT_ID loginParams.client_secret = process.env.VUE_APP_CLIENT_SECRET if (this.grantType === 'password' && customActiveKey === 'tab_account') { loginParams.grant_type = 'password' loginParams.password = values.password } else { if (customActiveKey === 'tab_account') { loginParams.grant_type = 'captcha' loginParams.password = values.password } else { loginParams.grant_type = 'sms_captcha' loginParams.phone_number = values.phoneNumber loginParams.code = values.captcha loginParams.smsCode = 'aliLoginCode' } // loginParams.password = md5(values.password) // 判断是图片验证码还是滑动验证码 if (this.loginCaptchaType === 'sliding') { loginParams.captcha_type = 'sliding' loginParams.sliding_type = this.slidingCaptchaType loginParams.captcha_verification = params.captchaVerification } else if (this.loginCaptchaType === 'image') { loginParams.captcha_type = 'image' loginParams.captcha_key = this.captchaKey loginParams.captcha_code = values.captchaCode } } Login(loginParams) .then((res) => this.loginSuccess(res)) .catch(err => this.requestFailed(err)) .finally(() => { state.loginBtn = false }) } else { setTimeout(() => { state.loginBtn = false }, 600) } }) }, // 滑动验证码校验 captchaVerify (e) { e.preventDefault() const { form: { validateFields }, state, customActiveKey } = this state.loginBtn = true const validateFieldsKey = customActiveKey === 'tab_account' ? ['username', 'password', 'vcode', 'verkey'] : ['phoneNumber', 'captcha', 'vcode', 'verkey'] validateFields(validateFieldsKey, { force: true }, (err, values) => { if (!err) { if (this.grantType === 'password') { this.verifySuccess() } else { if (this.loginCaptchaType === 'sliding') { this.$refs.verify.show() } else { this.verifySuccess() } } } else { setTimeout(() => { state.loginBtn = false }, 600) } }) }, queryCaptchaType () { getCaptchaType().then(res => { this.loginCaptchaType = res.data if (this.loginCaptchaType === 'image') { this.refreshImageCode() } }) }, refreshImageCode () { getImageCaptcha().then(res => { const data = res.data this.captchaKey = data.captchaKey this.captchaImage = data.captchaImage }) }, handleTabClick (key) { this.customActiveKey = key // this.form.resetFields() }, handleSubmit (e) { e.preventDefault() }, getCaptcha (e) { e.preventDefault() const { form: { validateFields }, state } = this validateFields(['phoneNumber'], { force: true }, (err, values) => { if (!err) { state.smsSendBtn = true const interval = window.setInterval(() => { if (state.time-- <= 0) { state.time = 60 state.smsSendBtn = false window.clearInterval(interval) } }, 1000) const hide = this.$message.loading('验证码发送中..', 0) getSmsCaptcha({ phoneNumber: values.phoneNumber, smsCode: 'aliLoginCode' }).then(res => { setTimeout(hide, 2500) this.$notification['success']({ message: '提示', description: '验证码获取成功,您的验证码为:' + res.result.captcha, duration: 8 }) }).catch(err => { setTimeout(hide, 1) clearInterval(interval) state.time = 60 state.smsSendBtn = false this.requestFailed(err) }) } }) }, stepCaptchaSuccess () { this.loginSuccess() }, stepCaptchaCancel () { this.Logout().then(() => { this.loginBtn = false this.stepCaptchaVisible = false }) }, loginSuccess (res) { // 判断是否记住密码 const rememberMe = this.form.getFieldValue('rememberMe') const username = this.form.getFieldValue('username') const password = this.form.getFieldValue('password') if (rememberMe && username !== '' && password !== '') { storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username', username, 60 * 60 * 24 * 7 * 1000) storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password', password, 60 * 60 * 24 * 7 * 1000) storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe', true, 60 * 60 * 24 * 7 * 1000) } else { storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username') storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password') storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe') } this.$router.push({ path: '/' }) // 延迟 1 秒显示欢迎信息 setTimeout(() => { this.$notification.success({ message: '欢迎', description: `${timeFix()},欢迎回来` }) }, 1000) this.isLoginError = false }, requestFailed (err) { this.isLoginError = true if (err && err.code === 427) { // 密码错误次数超过最大限值,请选择验证码模式登录 if (this.customActiveKey === 'tab_account') { this.grantType = 'captcha' } else { this.grantType = 'sms_captcha' } this.loginErrorMsg = err.msg if (this.loginCaptchaType === 'sliding') { this.$refs.verify.show() } } else if (err) { this.loginErrorMsg = err.msg } } }
7、在Nacos中增加配置项,默认使用行为验证码
#验证码配置 captcha: #验证码的类型 sliding: 滑动验证码 image: 图片验证码 type: sliding
8、登录效果
登录页
安全验证
尝试过多,账号被锁