Skip to content

Commit

Permalink
dashboard login with captcha
Browse files Browse the repository at this point in the history
  • Loading branch information
iGeeky committed Jun 18, 2023
1 parent 04a5824 commit bb242ec
Show file tree
Hide file tree
Showing 14 changed files with 322 additions and 3 deletions.
8 changes: 8 additions & 0 deletions console/src/api/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ export async function getLoginOptions() {
})
}

export async function getCaptchaData() {
return await request({
url: '/captcha',
method: 'get',
params: { },
})
}

export async function listUsers(args) {
return await request({
url: '/user/list',
Expand Down
2 changes: 2 additions & 0 deletions console/src/i18n/langs/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const en = {

'loginPromptUsername': 'Username',
'loginPromptPassword': 'Password',
'loginPromptCaptcha': 'Captcha',
'loginPromptLoginForm': 'Console Login',
'loginPromptStandardLogin': 'Standard',

Expand Down Expand Up @@ -248,6 +249,7 @@ const en = {

'ERR_USERNAME_MISSING': 'Username missing!',
'ERR_PASSWORD_MISSING': 'Password missing!',
'ERR_CAPTCHA_INVALID': 'captcha error',
'ERR_APPID_NOT_FOUND': 'Appid not found!',
'ERR_USER_NOT_FOUND': 'User not found',
'ERR_PASSWORD_ERROR': 'Password error',
Expand Down
2 changes: 2 additions & 0 deletions console/src/i18n/langs/zh-cn.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const zhcn = {

'loginPromptUsername': '用户名',
'loginPromptPassword': '密码',
'loginPromptCaptcha': '验证码',
'loginPromptLoginForm': '控制台登录',
'loginPromptStandardLogin': '标准登录',

Expand Down Expand Up @@ -248,6 +249,7 @@ const zhcn = {

'ERR_USERNAME_MISSING': '缺少用户名',
'ERR_PASSWORD_MISSING': '缺少密码',
'ERR_CAPTCHA_INVALID': '验证码错误',
'ERR_APPID_NOT_FOUND': 'Appid不存在',
'ERR_USER_NOT_FOUND': '用户不存在',
'ERR_PASSWORD_ERROR': '密码错误',
Expand Down
1 change: 1 addition & 0 deletions console/src/icons/svg/refresh.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
99 changes: 97 additions & 2 deletions console/src/views/login/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@
</span>
</el-form-item>
</el-tooltip>
<el-form-item v-if="captchaData.cid" prop="captchaText">
<div class="captcha">
<el-input
v-model="loginForm.captchaText"
:placeholder="$t('wolf.loginPromptCaptcha')"
name="captchaText"
tabindex="3"
minlength="4"
maxlength="6"
/>
<div class="captchaImage" v-html="captchaData.captcha" />
<div v-if="countdownNum > 0" class="refresh-icon">
{{ countdownNum }}
</div>
<svg-icon v-else icon-class="refresh" class="refresh-icon" @click="loadCaptchaData" />
</div>
</el-form-item>

<el-form-item v-if="showAuthTypeOption" prop="authType">
<el-radio-group v-model="loginForm.authType" @change="authTypeChange">
Expand All @@ -69,7 +86,7 @@
<script>
import { validUsername } from '@/utils/validate'
import SocialSign from './components/SocialSignin'
import { getLoginOptions } from '@/api/user'
import { getLoginOptions, getCaptchaData } from '@/api/user'
export default {
name: 'Login',
Expand All @@ -89,25 +106,40 @@ export default {
callback()
}
}
const validateCaptcha = (rule, value, callback) => {
if (!value || value.length < 4) {
callback(new Error('Please enter the correct captcha'))
} else {
callback()
}
}
return {
loginForm: {
username: '',
password: '',
captchaText: '',
authType: '1',
},
loginOptions: {
password: {},
ldap: {},
consoleLoginWithCaptcha: false,
},
captchaData: {
cid: '',
captcha: '',
},
loginRules: {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }],
captchaText: [{ required: true, trigger: 'blur', validator: validateCaptcha }],
authType: [{ required: true }],
},
passwordType: 'password',
capsTooltip: false,
loading: false,
showDialog: false,
countdownNum: 0,
redirect: undefined,
otherQuery: {},
}
Expand Down Expand Up @@ -147,6 +179,24 @@ export default {
// window.removeEventListener('storage', this.afterQRScan)
},
methods: {
countdown(num) {
this.countdownNum = num
const timer = setInterval(() => {
this.countdownNum--
if (this.countdownNum < 0) {
clearInterval(timer)
}
}, 1000)
return {
stop: function() {
clearInterval(timer)
},
getNum: function() {
return this.countdownNum
},
}
},
checkCapslock({ shiftKey, key } = {}) {
if (key && key.length === 1) {
if (shiftKey && (key >= 'a' && key <= 'z') || !shiftKey && (key >= 'A' && key <= 'Z')) {
Expand All @@ -173,18 +223,26 @@ export default {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true
this.$store.dispatch('user/login', this.loginForm)
const loginForm = { ...this.loginForm }
if (this.captchaData.cid) {
loginForm.cid = this.captchaData.cid
}
this.$store.dispatch('user/login', loginForm)
// login(this.loginForm)
.then((res) => {
this.loginForm.captchaText = ''
this.$router.push({ path: this.redirect || '/', query: this.otherQuery })
this.loading = false
})
.catch((err) => {
this.loginForm.captchaText = ''
this.loadCaptchaData()
this.loading = false
console.error('login failed! err: ', err)
})
} else {
console.error('error submit!!')
this.loadCaptchaData()
return false
}
})
Expand All @@ -196,9 +254,19 @@ export default {
if (!this.loginOptions.ldap.supported) {
this.loginForm.authType = '1'
}
await this.loadCaptchaData()
}
// console.log('loginOptions: %s', JSON.stringify(this.loginOptions))
},
async loadCaptchaData() {
this.countdownCounter = this.countdown(10)
if (this.loginOptions.consoleLoginWithCaptcha) {
const res = await getCaptchaData()
if (res.ok) {
this.captchaData = res.data
}
}
},
authTypeChange(label) {
localStorage.setItem('authType', label)
},
Expand Down Expand Up @@ -308,6 +376,33 @@ $light_gray:#eee;
padding: 15px 35px 0;
margin: 0 auto;
overflow: hidden;
.captcha {
display: flex;
height: 40px;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
.captchaImage {
display: flex;
align-items: center;
justify-content: center;
}
.refresh-icon {
display: flex;
width: 50px;
height: 40px;
align-items: center;
justify-content: center;
background: #060a10;
border-radius: 5px;
font-size: 25px;
color: #fbfbfb;
}
}
}
.tips {
Expand Down
1 change: 1 addition & 0 deletions docs/deploy-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ show tables;
* WOLF_CRYPT_KEY 加密应用Secret及OAuth2登陆用户ID使用的Key.
* RBAC_TOKEN_EXPIRE_TIME `Agent` 登录接口返回的token的有效期, 默认为30天. 单位为秒.
* CONSOLE_TOKEN_EXPIRE_TIME `Console` 登录接口返回的token的有效期, 默认为30天. 单位为秒.
* CONSOLE_LOGIN_WITH_CAPTCHA 控制 `Console` 登录是否使用 Captcha 验证码。如果控制台部署在公网上,建议开启该功能以提高安全性。该环境变量的取值为 yes 或 no,默认为 no。
* RBAC_SQL_URL 连接数据库的数据库链接. 默认为: `postgres://wolfroot:[email protected]:5432/wolf`
* RBAC_REDIS_URL redis缓存的链接. 默认为: `redis://127.0.0.1:6379/0`
* MEM_CACHE_BY_REDIS 使用redis作为对象缓存. 默认为`no`. 当要部署多节点的`wolf`服务时,可使用redis作为对象缓存,解决缓存不一致问题.
Expand Down
3 changes: 2 additions & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ The output should be similar to the following, indicating that the database tabl
* `RBAC_TOKEN_KEY`: A key used to encrypt the user token. It is highly recommended to set this value.
* `WOLF_CRYPT_KEY`: A key used to encrypt the application secret and OAuth2 login user ID keys.
* `RBAC_TOKEN_EXPIRE_TIME`: The expiration time of the token returned by the `Agent` login interface. The default is 30 days and the unit is seconds.
* `CONSOLE_TOKEN_EXPIRE_TIME`: The expiration time of the token returned by the Console login interface. The default is 30 days and the unit is seconds.
* `CONSOLE_TOKEN_EXPIRE_TIME`: The expiration time of the token returned by the `Console` login interface. The default is 30 days and the unit is seconds.
* `CONSOLE_LOGIN_WITH_CAPTCHA`: Controls whether Captcha verification is used for `Console` login. If the console is deployed on a public network, it is recommended to enable this feature to improve security. The environment variable can be set to `yes` or `no`, with a default value of `no`.
* `RBAC_SQL_URL`: The link to the database. The default value is `postgres://wolfroot:[email protected]:5432/wolf`.
* `RBAC_REDIS_URL`: The link to the redis cache. The default value is `redis://127.0.0.1:6379/0`.
* `MEM_CACHE_BY_REDIS`: Use redis as the object cache. The default is no. When deploying a multi-node wolf service, you can use redis as the object cache to resolve cache inconsistency issues.
Expand Down
1 change: 1 addition & 0 deletions server/conf/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const config = {
cryptKey: process.env.WOLF_CRYPT_KEY || 'fbd4962351924792cb5e5b131435cd30b24e3570',
rbacTokenExpireTime: parseInt(process.env.RBAC_TOKEN_EXPIRE_TIME) || 3600 * 24 * 30,
consoleTokenExpireTime: parseInt(process.env.CONSOLE_TOKEN_EXPIRE_TIME) || 3600 * 24 * 30,
consoleLoginWithCaptcha: ((process.env.CONSOLE_LOGIN_WITH_CAPTCHA || 'no') === 'yes'),
rbacRecordAccessLog: (process.env.RBAC_RECORD_ACCESS_LOG || 'yes') === 'yes',
memCacheTTLSecond: 600,
memCacheByRedis: (process.env.MEM_CACHE_BY_REDIS || 'no') === 'yes',
Expand Down
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"pg": "^8.7.3",
"pg-escape": "^0.2.0",
"sequelize": "^6.19.2",
"svg-captcha": "^1.4.0",
"urlsafe-base64": "^1.0.0",
"validator": ">=13.7.0"
},
Expand Down
25 changes: 25 additions & 0 deletions server/src/controllers/captcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const util = require('../util/util')
const {newCaptcha} = require('../util/captcha-util');

const BasicService = require('./basic-service')


class Captcha extends BasicService {
constructor(ctx) {
super(ctx, null)
}

async get() {
this.checkMethod('GET')
const {cid, data: captchaData} = await newCaptcha();
const data = {
"cid": cid,
"captcha": captchaData,
}
this.success(data);
}

}

module.exports = Captcha

11 changes: 11 additions & 0 deletions server/src/controllers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const UserModel = require('../model/user')
const AccessDenyError = require('../errors/access-deny-error')
const ApplicationModel = require('../model/application')
const UserRoleModel = require('../model/user-role')
const {captchaValidate} = require('../util/captcha-util');
const {like} = require('../util/op-util')
const Op = require('sequelize').Op;
const errors = require('../errors/errors')
Expand Down Expand Up @@ -71,6 +72,15 @@ class User extends BasicService {
const username = this.getRequiredArg('username')
const password = this.getRequiredArg('password')
const authType = this.getIntArg('authType', constant.AuthType.PASSWORD)
if (config.consoleLoginWithCaptcha) {
const cid = this.getRequiredArg('cid');
const captchaText = this.getRequiredArg('captchaText')
const {valid, errmsg} = await captchaValidate(cid, captchaText)
if (!valid) {
this.fail(200, errmsg);
return
}
}

this.log4js.info('### user[%s] login authType=%s...', username, authType)
const {userInfo, err: loginErr} = await this.userLoginInternal(username, password, {authType})
Expand Down Expand Up @@ -115,6 +125,7 @@ class User extends BasicService {
const data = {
password: {supported: true},
ldap: ldapOptions(),
consoleLoginWithCaptcha: config.consoleLoginWithCaptcha,
}
this.success(data)
}
Expand Down
1 change: 1 addition & 0 deletions server/src/middlewares/token-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const IGNORE_URLS = {
'POST:/wolf/user/login': true,
// 'POST:/wolf/user/logout': true,
'GET:/wolf/user/loginOptions': true,
'GET:/wolf/captcha': true,
}

function needCheckToken(ctx) {
Expand Down
53 changes: 53 additions & 0 deletions server/src/util/captcha-util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@

const config = require('../../conf/config')
const svgCaptcha = require('svg-captcha')
const log4js = require('./log4js')
const {redisClient} = require('./redis-util')
const util = require('./util')

const ERR_CAPTCHA_INVALID = 'ERR_CAPTCHA_INVALID'

function captchaKey(cid) {
return `cha:${cid}`
}


async function newCaptcha() {
const newCaptcha = svgCaptcha.create({
size: 4,
fontSize: 45,
noise: Math.floor(Math.random() * 4) + 1,
ignoreChars: '0o1i',
width: 120,
height: 40,
color: true,
background: '#ccc',
})
const text = newCaptcha.text;
const cid = util.randomString(20);
const key = captchaKey(cid);
const expiresIn = 60 * 5;
const res = await redisClient.set(key, text, 'EX', expiresIn);
if (res !== 'OK') {
throw new Error('redis set error');
}
const data = newCaptcha.data;
return {cid, data}
}

async function captchaValidate(cid, text) {
const key = captchaKey(cid);
const captchaText = await redisClient.get(key);
if (!captchaText) {
log4js.log("captcha {cid: %s} not found", cid);
return {valid: false, errmsg: ERR_CAPTCHA_INVALID}
}
if (captchaText != text) {
return {valid: false, errmsg: ERR_CAPTCHA_INVALID}
}
return {valid: true, errmsg: ''}
}


exports.newCaptcha = newCaptcha
exports.captchaValidate = captchaValidate
Loading

0 comments on commit bb242ec

Please sign in to comment.