Skip to content

Commit

Permalink
fix: use security svg-captcha and add more options (#4242)
Browse files Browse the repository at this point in the history
  • Loading branch information
czy88840616 authored Dec 29, 2024
1 parent 7f84d85 commit 8804ca2
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 75 deletions.
2 changes: 1 addition & 1 deletion packages/captcha/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@midwayjs/cache-manager": "^3.19.2",
"mini-svg-data-uri": "1.4.4",
"nanoid": "3.3.8",
"svg-captcha": "1.4.0"
"svg-captcha-fixed": "1.5.2"
},
"devDependencies": {
"@midwayjs/core": "^3.19.0",
Expand Down
29 changes: 12 additions & 17 deletions packages/captcha/src/interface.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
interface BaseCaptchaOptions {
// 验证码长度,默认4
size?: number;
// 干扰线条的数量,默认1
noise?: number;
// 宽度、高度
width?: number;
height?: number;
import { ConfigObject } from 'svg-captcha-fixed';

export interface CaptchaCacheOptions {
// 验证码过期时间,单位秒
expirationTime?: number;
// 验证码 key 前缀
idPrefix?: string;
}

export interface CaptchaOptions extends BaseCaptchaOptions {
default?: BaseCaptchaOptions;
export interface CaptchaOptions extends CaptchaCacheOptions {
default?: ConfigObject;
image?: ImageCaptchaOptions;
formula?: FormulaCaptchaOptions;
text?: TextCaptchaOptions;
// 验证码过期时间,默认为 1h
expirationTime?: number;
// 验证码key 前缀
idPrefix?: string;
}

export interface ImageCaptchaOptions extends BaseCaptchaOptions {
export interface ImageCaptchaOptions extends ConfigObject {
type?: 'number'|'letter'|'mixed';
}

export interface FormulaCaptchaOptions extends BaseCaptchaOptions {}
export interface FormulaCaptchaOptions extends ConfigObject {}

export interface TextCaptchaOptions {
size?: number;
type?: 'number'|'letter'|'mixed';
}
}
118 changes: 62 additions & 56 deletions packages/captcha/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ import {
InjectClient,
} from '@midwayjs/core';
import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager';
import * as svgCaptcha from 'svg-captcha';
import * as svgCaptcha from 'svg-captcha-fixed';
import * as svgBase64 from 'mini-svg-data-uri';
import { nanoid } from 'nanoid';
import {
FormulaCaptchaOptions,
ImageCaptchaOptions,
TextCaptchaOptions,
CaptchaOptions,
CaptchaCacheOptions,
} from './interface';
import { letters, numbers } from './constants';

const DEFAULT_IMAGE_IGNORE_CHARS = {
letter: numbers,
number: letters,
};

@Provide()
@Scope(ScopeEnum.Singleton)
export class CaptchaService {
Expand All @@ -25,69 +32,63 @@ export class CaptchaService {
@Config('captcha')
protected captcha: CaptchaOptions;

async image(options?: ImageCaptchaOptions): Promise<{
async image(
options?: ImageCaptchaOptions,
cacheOption?: CaptchaCacheOptions
): Promise<{
id: string;
imageBase64: string;
}> {
const { width, height, type, size, noise } = Object.assign(
{},
this.captcha,
this.captcha.default,
this.captcha.image,
options
);
let ignoreChars = '';
switch (type) {
case 'letter':
ignoreChars = numbers;
break;
case 'number':
ignoreChars = letters;
break;
}
// const { expirationTime, idPrefix } = this.captcha;
const { type, ...others }: ImageCaptchaOptions = {
...this.captcha.default,
...this.captcha.image,
...options,
};

const { data, text } = svgCaptcha.create({
ignoreChars,
width,
height,
size,
noise,
ignoreChars: DEFAULT_IMAGE_IGNORE_CHARS[type] ?? '',
...others,
});
const id = await this.set(text);
const id = await this.set(text, cacheOption);
const imageBase64 = svgBase64(data);
return { id, imageBase64 };
}

async formula(options?: FormulaCaptchaOptions) {
const { width, height, noise } = Object.assign(
{},
this.captcha,
this.captcha.default,
this.captcha.formula,
options
);
const { data, text } = svgCaptcha.createMathExpr({
width,
height,
noise,
});
const id = await this.set(text);
async formula(
options?: FormulaCaptchaOptions,
cacheOption?: CaptchaCacheOptions
): Promise<{
id: string;
imageBase64: string;
}> {
const formulaCaptchaOptions = {
...this.captcha.default,
...this.captcha.formula,
...options,
};

const { data, text } = svgCaptcha.createMathExpr(formulaCaptchaOptions);
const id = await this.set(text, cacheOption);
const imageBase64 = svgBase64(data);
return { id, imageBase64 };
}

async text(options?: TextCaptchaOptions): Promise<{
async text(
options?: TextCaptchaOptions,
cacheOption?: CaptchaCacheOptions
): Promise<{
id: string;
text: string;
}> {
const textOptions = Object.assign(
{},
this.captcha,
this.captcha.default,
this.captcha.text,
options
);
const { type, ...textOptions }: TextCaptchaOptions = {
...this.captcha.default,
...this.captcha.text,
...options,
};

let chars = '';
switch (textOptions.type) {
switch (type) {
case 'letter':
chars = letters;
break;
Expand All @@ -102,25 +103,29 @@ export class CaptchaService {
while (textOptions.size--) {
text += chars[Math.floor(Math.random() * chars.length)];
}
const id = await this.set(text);
const id = await this.set(text, cacheOption);
return { id, text };
}

async set(text: string): Promise<string> {
async set(text: string, cacheOptions?: CaptchaCacheOptions): Promise<string> {
const id = nanoid();
await this.captchaCaching.set(
this.getStoreId(id),
this.getStoreId(id, cacheOptions),
(text || '').toLowerCase(),
this.captcha.expirationTime * 1000
(cacheOptions?.expirationTime ?? this.captcha.expirationTime) * 1000
);
return id;
}

async check(id: string, value: string): Promise<boolean> {
async check(
id: string,
value: string,
cacheOptions?: CaptchaCacheOptions
): Promise<boolean> {
if (!id || !value) {
return false;
}
const storeId = this.getStoreId(id);
const storeId = this.getStoreId(id, cacheOptions);
const storedValue = await this.captchaCaching.get(storeId);
if (value.toLowerCase() !== storedValue) {
return false;
Expand All @@ -129,10 +134,11 @@ export class CaptchaService {
return true;
}

private getStoreId(id: string): string {
if (!this.captcha.idPrefix) {
private getStoreId(id: string, cacheOptions?: CaptchaCacheOptions): string {
const idPrefix = cacheOptions?.idPrefix ?? this.captcha.idPrefix;
if (!idPrefix) {
return id;
}
return `${this.captcha.idPrefix}:${id}`;
return `${idPrefix}:${id}`;
}
}
8 changes: 8 additions & 0 deletions site/docs/extensions/captcha.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ export const captcha: CaptchaOptions = {
idPrefix: 'midway:vc',
}
```

更多配置请参考 [svg-captcha](https://github.com/produck/svg-captcha)

### 配置示例一

获取一个 包含 `5个纯英文字母` 的图像验证码,图像宽度 `200` 像素,高度 `50` 像素,并且包含 `3` 条干扰线。
Expand Down Expand Up @@ -285,3 +288,8 @@ export default {
**计算表达式**

![计算表达式](https://gw.alicdn.com/imgextra/i4/O1CN01u3Mj0q24lRx1md9pX_!!6000000007431-2-tps-120-40.png)


## 注意

* 为了防止机器学习破解,使用的 `svg-captcha` 包为 [安全修复后](https://juejin.cn/post/6872656117839691789) 的版本。
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export const captcha: CaptchaOptions = {
}
```

More configurations can be found in [svg-captcha](https://github.com/produck/svg-captcha).

### Configuration Example 1

Get an image captcha code containing `5 pure English letters`. The image's width is `200` pixels, the height is `50` pixels, and it contains `3` noise lines.
Expand Down Expand Up @@ -285,4 +287,9 @@ If you want to replace it with 'redis' or other services, please refer to the [d

**Calculation expression**

![计算表达式](https://gw.alicdn.com/imgextra/i4/O1CN01u3Mj0q24lRx1md9pX_!!6000000007431-2-tps-120-40.png)
![计算表达式](https://gw.alicdn.com/imgextra/i4/O1CN01u3Mj0q24lRx1md9pX_!!6000000007431-2-tps-120-40.png)


## Tips

* In order to prevent machine learning cracking, the `svg-captcha` package used is the version after [security repair](https://juejin.cn/post/6872656117839691789).

0 comments on commit 8804ca2

Please sign in to comment.