隨著近幾年技術的發展,人們對於系統安全性和使用者體驗的要求越來越高,大多數網站系統都逐漸採用行為驗證碼來代替圖片驗證碼。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-icon></a-icon></a-input>
</a-form-item>
</a-col>
<a-col :span="10">
<img :src="captchaImage" class="v-code-img" @click="refreshImageCode" src="">
</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、登入效果
原始碼地址:
Gitee: https://gitee.com/wmz1930/GitEgg
GitHub: https://github.com/wmz1930/GitEgg