SpringCloud微服务实战——搭建企业级开发框架(二十四):集成行为验证码和图片验证码实现登录功能
About 6 minspringcloud实战
SpringCloud微服务实战——搭建企业级开发框架(二十四):集成行为验证码和图片验证码实现登录功能
随着近几年技术的发展,人们对于系统安全性和用户体验的要求越来越高,大多数网站系统都逐渐采用行为验证码来代替图片验证码。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、登录效果