增加登录验证码功能

This commit is contained in:
2026-04-03 23:12:07 +08:00
parent d008278b6a
commit d4cc7a6b73
9 changed files with 261 additions and 33 deletions

View File

@@ -46,6 +46,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
// API 公开接口
.requestMatchers("/api/auth/login", "/api/auth/register", "/api/auth/logout").permitAll()
.requestMatchers("/api/captcha").permitAll()
.requestMatchers("/api/files/test").permitAll()
.requestMatchers("/api/users/config").permitAll()
// WebSocket

View File

@@ -4,6 +4,7 @@ import com.filesystem.entity.User;
import com.filesystem.security.UserPrincipal;
import com.filesystem.service.UserService;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -19,17 +20,23 @@ public class AuthController {
private UserService userService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
public ResponseEntity<?> login(@RequestBody Map<String, String> request, HttpSession session) {
String username = request.get("username");
String password = request.get("password");
String captcha = request.get("captcha");
// 验证码校验
if (!CaptchaController.verify(session, captcha)) {
return ResponseEntity.badRequest().body(Map.of("message", "验证码错误或已过期"));
}
try {
String token = userService.login(username, password);
User user = userService.findByUsername(username);
// 精确重算存储空间
long storageUsed = userService.recalculateStorage(user.getId());
long storageLimit = user.getStorageLimit();
// 直接读取存储空间,不重算
long storageUsed = user.getStorageUsed() != null ? user.getStorageUsed() : 0L;
long storageLimit = user.getStorageLimit() != null ? user.getStorageLimit() : 0L;
Map<String, Object> userData = new HashMap<>();
userData.put("id", user.getId());

View File

@@ -0,0 +1,130 @@
package com.filesystem.controller;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
@RestController
@RequestMapping("/api/captcha")
public class CaptchaController {
private static final String CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
private static final int WIDTH = 140;
private static final int HEIGHT = 40;
private static final int CODE_LENGTH = 5;
@GetMapping
public ResponseEntity<?> getCaptcha(HttpSession session) {
// 生成随机验证码
String code = generateCode();
// 存入 session
session.setAttribute("captcha", code.toLowerCase());
session.setAttribute("captchaTime", System.currentTimeMillis());
// 生成图片
String imageBase64 = generateCaptchaImage(code);
return ResponseEntity.ok(Map.of(
"data", Map.of(
"image", "data:image/png;base64," + imageBase64
)
));
}
/**
* 验证验证码
*/
public static boolean verify(HttpSession session, String inputCode) {
if (inputCode == null || inputCode.isEmpty()) {
return false;
}
String storedCode = (String) session.getAttribute("captcha");
Long captchaTime = (Long) session.getAttribute("captchaTime");
if (storedCode == null || captchaTime == null) {
return false;
}
// 验证码 5 分钟有效
if (System.currentTimeMillis() - captchaTime > 5 * 60 * 1000) {
session.removeAttribute("captcha");
session.removeAttribute("captchaTime");
return false;
}
// 验证后清除,防止重复使用
session.removeAttribute("captcha");
session.removeAttribute("captchaTime");
return storedCode.equals(inputCode.toLowerCase());
}
private String generateCode() {
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
}
return sb.toString();
}
private String generateCaptchaImage(String code) {
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
// 设置抗锯齿
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 背景
g.setColor(Color.WHITE);
g.fillRect(0, 0, WIDTH, HEIGHT);
// 干扰线
Random random = new Random();
for (int i = 0; i < 6; i++) {
g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200)));
g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT),
random.nextInt(WIDTH), random.nextInt(HEIGHT));
}
// 干扰点
for (int i = 0; i < 40; i++) {
g.setColor(new Color(random.nextInt(255), random.nextInt(255), random.nextInt(255)));
g.fillOval(random.nextInt(WIDTH), random.nextInt(HEIGHT), 2, 2);
}
// 验证码文字
g.setFont(new Font("Arial", Font.BOLD, 26));
for (int i = 0; i < code.length(); i++) {
// 随机颜色
g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150)));
// 随机角度
double angle = (random.nextDouble() - 0.5) * 0.4;
g.rotate(angle, 18 + i * 24, 26);
g.drawString(String.valueOf(code.charAt(i)), 10 + i * 24, 28);
g.rotate(-angle, 18 + i * 24, 26);
}
g.dispose();
// 转为 Base64
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
javax.imageio.ImageIO.write(image, "png", baos);
return Base64.getEncoder().encodeToString(baos.toByteArray());
} catch (IOException e) {
return "";
}
}
}

View File

@@ -165,6 +165,8 @@ public class MessageController {
m.put("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
m.put("fromAvatar", fromUser.getAvatar());
m.put("fromSignature", fromUser.getSignature());
m.put("fromPhone", fromUser.getPhone());
m.put("fromEmail", fromUser.getEmail());
}
return m;
}).collect(Collectors.toList());

View File

@@ -89,6 +89,8 @@ public class ChatHandler extends TextWebSocketHandler {
messageData.put("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
messageData.put("fromAvatar", fromUser.getAvatar());
messageData.put("fromSignature", fromUser.getSignature());
messageData.put("fromPhone", fromUser.getPhone());
messageData.put("fromEmail", fromUser.getEmail());
}
Map<String, Object> resp = Map.of("type", "chat", "message", messageData);

View File

@@ -5,3 +5,5 @@ export const login = (data) => request.post('/auth/login', data)
export const register = (data) => request.post('/auth/register', data)
export const getCurrentUser = () => request.get('/auth/info')
export const getCaptcha = () => request.get('/captcha')

View File

@@ -84,7 +84,7 @@
</div>
<template v-else>
<div v-for="msg in currentMessages" :key="msg.id" class="message-wrapper" :class="{ self: msg.isSelf }">
<el-popover placement="left" :width="200" trigger="hover">
<el-popover :placement="msg.isSelf ? 'left' : 'right'" :width="240" trigger="hover">
<template #reference>
<el-avatar
:size="30"
@@ -104,9 +104,17 @@
<span class="user-info-label">昵称:</span>
<span class="user-info-value">{{ msg.isSelf ? (currentUserNickname || currentUserName) : (msg.fromNickname || '未知') }}</span>
</div>
<div class="user-info-row signature-row">
<div class="user-info-row">
<span class="user-info-label">电话:</span>
<span class="user-info-value">{{ msg.isSelf ? (userStore.phone || '暂无') : (msg.fromPhone || '暂无') }}</span>
</div>
<div class="user-info-row">
<span class="user-info-label">邮箱:</span>
<span class="user-info-value">{{ msg.isSelf ? (userStore.email || '暂无') : (msg.fromEmail || '暂无') }}</span>
</div>
<div class="user-info-row">
<span class="user-info-label">签名:</span>
<div class="user-info-signature">
<div class="user-info-signature-box">
{{ msg.isSelf ? (userStore.signature || '暂无') : (msg.fromSignature || '暂无') }}
</div>
</div>
@@ -753,12 +761,23 @@ onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
.uploading-mini { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; color: #409eff; font-size: 12px; background: #ecf5ff; border-radius: 4px; }
.uploading-mini .el-icon { font-size: 14px; }
.user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; }
.user-info-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; }
.user-info-label { font-weight: 500; min-width: 50px; color: #303133; }
.user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
.user-info-row { display: flex; align-items: flex-start; gap: 8px; font-size: 12px; line-height: 1.5; }
.user-info-label { font-weight: 500; min-width: 50px; color: #303133; flex-shrink: 0; padding-top: 6px; }
.user-info-value { color: #606266; word-break: break-all; flex: 1; }
.signature-row { flex-direction: column; align-items: flex-start; }
.user-info-signature { color: #606266; font-size: 12px; line-height: 1.4; word-break: break-all; margin-top: 4px; }
.user-info-signature-box {
flex: 1;
padding: 6px 10px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
color: #606266;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
max-height: 80px;
overflow-y: auto;
}
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #909399; gap: 12px; }
.chat-empty p { margin: 0; font-size: 14px; }

View File

@@ -9,7 +9,7 @@
<el-upload
drag
multiple
:limit="11"
:limit="31"
:auto-upload="false"
:on-change="handleChange"
:on-exceed="handleExceed"
@@ -17,7 +17,7 @@
:show-file-list="false"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽文件到此处<em>点击选择</em>最多10个</div>
<div class="el-upload__text">拖拽文件到此处<em>点击选择</em>最多30个</div>
</el-upload>
<div class="storage-info">
@@ -98,16 +98,16 @@ const isOverSizeLimit = computed(() => {
})
const handleChange = (file, list) => {
if (list.length > 10) {
ElMessage.warning('最多一次上传10个文件')
fileList.value = list.slice(0, 10)
if (list.length > 30) {
ElMessage.warning('最多一次上传30个文件')
fileList.value = list.slice(0, 30)
return
}
fileList.value = list
}
const handleExceed = () => {
ElMessage.warning('最多一次上传10个文件')
ElMessage.warning('最多一次上传30个文件')
}
const removeFile = (index) => {

View File

@@ -19,48 +19,90 @@
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="captcha">
<div class="captcha-row">
<el-input
v-model="form.captcha"
placeholder="请输入验证码"
clearable
@keyup.enter="handleLogin"
>
<template #prefix><el-icon><Key /></el-icon></template>
</el-input>
<img
:src="captchaImage"
class="captcha-img"
@click="refreshCaptcha"
title="点击刷新验证码"
/>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
:disabled="!isFormComplete"
native-type="submit"
style="width: 100%"
>
<template v-if="loading">
<span>登录中...</span>
</template>
<template v-else>
<el-icon><Right /></el-icon>
<span style="margin-left: 4px">登录</span>
</template>
</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Lock, Right } from '@element-plus/icons-vue'
import { login } from '@/api/auth'
import { User, Lock, Right, Key } from '@element-plus/icons-vue'
import { login, getCaptcha } from '@/api/auth'
const emit = defineEmits(['success'])
const formRef = ref(null)
const loading = ref(false)
const captchaImage = ref('')
const form = reactive({ username: '', password: '' })
const form = reactive({ username: '', password: '', captcha: '' })
// 表单是否填写完整
const isFormComplete = computed(() => {
return form.username.trim() && form.password.trim() && form.captcha.trim()
})
const rules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
}
const refreshCaptcha = async () => {
try {
const res = await getCaptcha()
captchaImage.value = res.data.image
} catch (e) {
console.error('获取验证码失败', e)
}
}
const handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || loading.value) return
if (!isFormComplete.value || loading.value) return
loading.value = true
try {
const res = await login(form)
// res.data = { token, user }
ElMessage.success('登录成功,正在跳转...')
emit('success', res.data)
} catch (e) {
// 登录失败刷新验证码
refreshCaptcha()
form.captcha = ''
if (!e.response) {
ElMessage.error('后端服务不可用,请稍后重试')
} else if (e.response.status >= 500) {
@@ -72,13 +114,36 @@ const handleLogin = async () => {
loading.value = false
}
}
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped>
.form-tips {
text-align: center;
color: #c0c4cc;
font-size: 12px;
margin-top: -8px;
.captcha-row {
display: flex;
gap: 12px;
width: 100%;
align-items: center;
}
.captcha-row .el-input {
flex: 1;
}
.captcha-img {
width: 140px;
height: 32px;
cursor: pointer;
border-radius: 4px;
border: 1px solid #dcdfe6;
object-fit: fill;
background: #fff;
flex-shrink: 0;
}
.captcha-img:hover {
border-color: #409eff;
}
</style>