Skip to content

Commit c838017

Browse files
gadfly3173colorful3
authored andcommitted
feat: 登录验证码
1 parent af53c67 commit c838017

File tree

13 files changed

+349
-2
lines changed

13 files changed

+349
-2
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package io.github.talelin.latticy.bo;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Data;
5+
import lombok.NoArgsConstructor;
6+
7+
/**
8+
* @author Gadfly
9+
* @since 2021-11-19 15:20
10+
*/
11+
@Data
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
public class LoginCaptchaBO {
15+
private String captcha;
16+
private Long expired;
17+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.github.talelin.latticy.common.configuration;
2+
3+
import io.github.talelin.latticy.common.util.CaptchaUtil;
4+
import lombok.Getter;
5+
import lombok.Setter;
6+
import org.springframework.boot.context.properties.ConfigurationProperties;
7+
import org.springframework.stereotype.Component;
8+
9+
/**
10+
* @author Gadfly
11+
*/
12+
@Getter
13+
@Setter
14+
@Component
15+
@ConfigurationProperties(prefix = "login-captcha")
16+
public class LoginCaptchaProperties {
17+
/**
18+
* aes 密钥
19+
*/
20+
private String secret;
21+
/**
22+
* aes 偏移量
23+
*/
24+
private String iv = CaptchaUtil.getRandomString(16);
25+
/**
26+
* 启用验证码
27+
*/
28+
private Boolean enabled = Boolean.FALSE;
29+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package io.github.talelin.latticy.common.util;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import io.github.talelin.latticy.bo.LoginCaptchaBO;
6+
import org.springframework.core.io.ClassPathResource;
7+
8+
import javax.crypto.Cipher;
9+
import javax.crypto.SecretKey;
10+
import javax.crypto.spec.IvParameterSpec;
11+
import javax.crypto.spec.SecretKeySpec;
12+
import javax.imageio.ImageIO;
13+
import java.awt.*;
14+
import java.awt.image.BufferedImage;
15+
import java.io.ByteArrayOutputStream;
16+
import java.io.IOException;
17+
import java.nio.charset.StandardCharsets;
18+
import java.security.GeneralSecurityException;
19+
import java.time.LocalDateTime;
20+
import java.time.ZoneId;
21+
import java.util.Base64;
22+
import java.util.Random;
23+
24+
/**
25+
* @author Gadfly
26+
*/
27+
@SuppressWarnings("SpellCheckingInspection")
28+
public class CaptchaUtil {
29+
30+
/**
31+
* 验证码字符个数
32+
*/
33+
public static final int RANDOM_STR_NUM = 4;
34+
private static final Random RANDOM = new Random();
35+
/**
36+
* 验证码的宽
37+
*/
38+
private static final int WIDTH = 80;
39+
/**
40+
* 验证码的高
41+
*/
42+
private static final int HEIGHT = 40;
43+
/**
44+
* 验证码中夹杂的干扰线数量
45+
*/
46+
private static final int LINE_SIZE = 30;
47+
private static final String RANDOM_STRING = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWSYZ";
48+
private static final String AES = "AES";
49+
private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
50+
private static final ObjectMapper MAPPER = new ObjectMapper();
51+
52+
static {
53+
java.security.Security.setProperty("crypto.policy", "unlimited");
54+
}
55+
56+
/**
57+
* 颜色的设置
58+
*/
59+
private static Color getRandomColor(int fc, int bc) {
60+
61+
fc = Math.min(fc, 255);
62+
bc = Math.min(bc, 255);
63+
64+
int r = fc + RANDOM.nextInt(bc - fc - 16);
65+
int g = fc + RANDOM.nextInt(bc - fc - 14);
66+
int b = fc + RANDOM.nextInt(bc - fc - 12);
67+
68+
return new Color(r, g, b);
69+
}
70+
71+
/**
72+
* 字体的设置
73+
*/
74+
private static Font getFont() throws IOException, FontFormatException {
75+
ClassPathResource dejavuSerifBold = new ClassPathResource("DejaVuSerif-Bold.ttf");
76+
return Font.createFont(Font.TRUETYPE_FONT, dejavuSerifBold.getInputStream()).deriveFont(Font.BOLD, 24);
77+
}
78+
79+
/**
80+
* 随机字符的获取
81+
*/
82+
public static String getRandomString(int num) {
83+
num = num > 0 ? num : RANDOM_STRING.length();
84+
StringBuilder sb = new StringBuilder();
85+
for (int i = 0; i < num; i++) {
86+
int number = RANDOM.nextInt(RANDOM_STRING.length());
87+
sb.append(RANDOM_STRING.charAt(number));
88+
}
89+
return sb.toString();
90+
}
91+
92+
/**
93+
* 干扰线的绘制
94+
*/
95+
private static void drawLine(Graphics2D g) {
96+
int x = RANDOM.nextInt(WIDTH);
97+
int y = RANDOM.nextInt(HEIGHT);
98+
int xl = WIDTH;
99+
int yl = HEIGHT;
100+
g.setStroke(new BasicStroke(2.0f));
101+
g.setColor(getRandomColor(98, 200));
102+
g.drawLine(x, y, x + xl, y + yl);
103+
}
104+
105+
/**
106+
* 字符串的绘制
107+
*/
108+
private static void drawString(Graphics2D g, String randomStr, int i) throws IOException, FontFormatException {
109+
g.setFont(getFont());
110+
g.setColor(getRandomColor(28, 130));
111+
// 设置每个字符的随机旋转
112+
double radianPercent = (RANDOM.nextBoolean() ? -1 : 1) * Math.PI * (RANDOM.nextInt(60) / 320D);
113+
g.rotate(radianPercent, WIDTH * 0.8 / RANDOM_STR_NUM * i, HEIGHT / 2);
114+
int y = (RANDOM.nextBoolean() ? -1 : 1) * RANDOM.nextInt(4) + 4;
115+
g.translate(RANDOM.nextInt(3), y);
116+
g.drawString(randomStr, WIDTH / RANDOM_STR_NUM * i, HEIGHT / 2);
117+
g.rotate(-radianPercent, WIDTH * 0.8 / RANDOM_STR_NUM * i, HEIGHT / 2);
118+
g.translate(0, -y);
119+
}
120+
121+
private static BufferedImage getBufferedImage(String code) throws IOException, FontFormatException {
122+
// BufferedImage类是具有缓冲区的Image类,Image类是用于描述图像信息的类
123+
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_BGR);
124+
Graphics2D g = (Graphics2D) image.getGraphics();
125+
g.fillRect(0, 0, WIDTH, HEIGHT);
126+
g.setColor(getRandomColor(105, 189));
127+
g.setFont(getFont());
128+
int lineSize = RANDOM.nextInt(5);
129+
// 干扰线
130+
for (int i = 0; i < lineSize; i++) {
131+
drawLine(g);
132+
}
133+
// 随机字符
134+
for (int i = 0; i < code.length(); i++) {
135+
drawString(g, String.valueOf(code.charAt(i)), i);
136+
}
137+
g.dispose();
138+
return image;
139+
}
140+
141+
/**
142+
* 生成随机图片的base64编码字符串
143+
*
144+
* @param code 验证码
145+
* @return base64
146+
*/
147+
public static String getRandomCodeBase64(String code) throws IOException, FontFormatException {
148+
BufferedImage image = getBufferedImage(code);
149+
// 返回 base64
150+
ByteArrayOutputStream bos = new ByteArrayOutputStream();
151+
ImageIO.write(image, "PNG", bos);
152+
153+
byte[] bytes = bos.toByteArray();
154+
Base64.Encoder encoder = Base64.getEncoder();
155+
156+
return encoder.encodeToString(bytes);
157+
}
158+
159+
public static String getTag(String captcha, String secret, String iv) throws JsonProcessingException, GeneralSecurityException {
160+
LocalDateTime time = LocalDateTime.now().plusMinutes(5);
161+
LoginCaptchaBO captchaBO = new LoginCaptchaBO(captcha, time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
162+
String json = MAPPER.writeValueAsString(captchaBO);
163+
return aesEncode(secret, iv, json);
164+
}
165+
166+
public static LoginCaptchaBO decodeTag(String secret, String iv, String tag) throws JsonProcessingException, GeneralSecurityException {
167+
String decrypted = aesDecode(secret, iv, tag);
168+
return MAPPER.readValue(decrypted, LoginCaptchaBO.class);
169+
}
170+
171+
/**
172+
* AES加密
173+
*/
174+
public static String aesEncode(String secret, String iv, String content) throws GeneralSecurityException {
175+
SecretKey secretKey = new SecretKeySpec(secret.getBytes(), AES);
176+
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
177+
System.out.println(iv.length() + "///" + iv.getBytes(StandardCharsets.UTF_8).length + "///" + iv.getBytes(StandardCharsets.US_ASCII).length);
178+
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv.getBytes(StandardCharsets.US_ASCII)));
179+
byte[] byteEncode = content.getBytes(StandardCharsets.UTF_8);
180+
// 根据密码器的初始化方式加密
181+
byte[] byteAES = cipher.doFinal(byteEncode);
182+
183+
// 将加密后的数据转换为字符串
184+
return Base64.getEncoder().encodeToString(byteAES);
185+
}
186+
187+
/**
188+
* AES解密
189+
*/
190+
public static String aesDecode(String secret, String iv, String content) throws GeneralSecurityException {
191+
SecretKey secretKey = new SecretKeySpec(secret.getBytes(), AES);
192+
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
193+
cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv.getBytes(StandardCharsets.US_ASCII)));
194+
// 将加密并编码后的内容解码成字节数组
195+
byte[] byteContent = Base64.getDecoder().decode(content);
196+
// 解密
197+
byte[] byteDecode = cipher.doFinal(byteContent);
198+
return new String(byteDecode, StandardCharsets.UTF_8);
199+
}
200+
}

src/main/java/io/github/talelin/latticy/controller/cms/UserController.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.talelin.latticy.controller.cms;
22

3+
import io.github.talelin.autoconfigure.exception.ForbiddenException;
34
import io.github.talelin.autoconfigure.exception.NotFoundException;
45
import io.github.talelin.autoconfigure.exception.ParameterException;
56
import io.github.talelin.core.annotation.AdminRequired;
@@ -9,6 +10,7 @@
910
import io.github.talelin.core.token.DoubleJWT;
1011
import io.github.talelin.core.token.Tokens;
1112
import io.github.talelin.latticy.common.LocalUser;
13+
import io.github.talelin.latticy.common.configuration.LoginCaptchaProperties;
1214
import io.github.talelin.latticy.dto.user.ChangePasswordDTO;
1315
import io.github.talelin.latticy.dto.user.LoginDTO;
1416
import io.github.talelin.latticy.dto.user.RegisterDTO;
@@ -19,15 +21,18 @@
1921
import io.github.talelin.latticy.service.UserIdentityService;
2022
import io.github.talelin.latticy.service.UserService;
2123
import io.github.talelin.latticy.vo.CreatedVO;
24+
import io.github.talelin.latticy.vo.LoginCaptchaVO;
2225
import io.github.talelin.latticy.vo.UpdatedVO;
2326
import io.github.talelin.latticy.vo.UserInfoVO;
2427
import io.github.talelin.latticy.vo.UserPermissionVO;
2528
import org.springframework.beans.factory.annotation.Autowired;
29+
import org.springframework.util.StringUtils;
2630
import org.springframework.validation.annotation.Validated;
2731
import org.springframework.web.bind.annotation.GetMapping;
2832
import org.springframework.web.bind.annotation.PostMapping;
2933
import org.springframework.web.bind.annotation.PutMapping;
3034
import org.springframework.web.bind.annotation.RequestBody;
35+
import org.springframework.web.bind.annotation.RequestHeader;
3136
import org.springframework.web.bind.annotation.RequestMapping;
3237
import org.springframework.web.bind.annotation.RestController;
3338

@@ -56,6 +61,9 @@ public class UserController {
5661
@Autowired
5762
private DoubleJWT jwt;
5863

64+
@Autowired
65+
private LoginCaptchaProperties captchaConfig;
66+
5967
/**
6068
* 用户注册
6169
*/
@@ -70,7 +78,16 @@ public CreatedVO register(@RequestBody @Validated RegisterDTO validator) {
7078
* 用户登陆
7179
*/
7280
@PostMapping("/login")
73-
public Tokens login(@RequestBody @Validated LoginDTO validator) {
81+
public Tokens login(@RequestBody @Validated LoginDTO validator, @RequestHeader("Tag") String tag) {
82+
// TODO: 使用spring validation验证。暂时还没想到怎么根据配置文件分组
83+
if (captchaConfig.getEnabled()) {
84+
if (!StringUtils.hasText(validator.getCaptcha()) || !StringUtils.hasText(tag)) {
85+
throw new ParameterException("验证码不可为空");
86+
}
87+
if (!userService.verifyCaptcha(validator.getCaptcha(), tag)) {
88+
throw new ForbiddenException(10260);
89+
}
90+
}
7491
UserDO user = userService.getUserByUsername(validator.getUsername());
7592
if (user == null) {
7693
throw new NotFoundException(10021);
@@ -85,6 +102,14 @@ public Tokens login(@RequestBody @Validated LoginDTO validator) {
85102
return jwt.generateTokens(user.getId());
86103
}
87104

105+
@PostMapping("/captcha")
106+
public LoginCaptchaVO userCaptcha() throws Exception {
107+
if (captchaConfig.getEnabled()) {
108+
return userService.generateCaptcha();
109+
}
110+
return new LoginCaptchaVO();
111+
}
112+
88113
/**
89114
* 更新用户信息
90115
*/

src/main/java/io/github/talelin/latticy/dto/user/LoginDTO.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ public class LoginDTO {
1818

1919
@NotBlank(message = "{password.new.not-blank}")
2020
private String password;
21+
22+
private String captcha;
2123
}

src/main/java/io/github/talelin/latticy/service/UserService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import io.github.talelin.latticy.model.GroupDO;
1010
import io.github.talelin.latticy.model.PermissionDO;
1111
import io.github.talelin.latticy.model.UserDO;
12+
import io.github.talelin.latticy.vo.LoginCaptchaVO;
1213

1314
import java.util.List;
1415
import java.util.Map;
@@ -129,4 +130,18 @@ public interface UserService extends IService<UserDO> {
129130
* @return 超级管理员的id
130131
*/
131132
Integer getRootUserId();
133+
134+
/**
135+
* 生成无状态的登录验证码
136+
*
137+
* @return 验证码
138+
*/
139+
LoginCaptchaVO generateCaptcha() throws Exception;
140+
141+
/**
142+
* 校验登录验证码
143+
*
144+
* @return 结果
145+
*/
146+
boolean verifyCaptcha(String captcha, String tag);
132147
}

0 commit comments

Comments
 (0)