增加登录验证码功能

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 .authorizeHttpRequests(auth -> auth
// API 公开接口 // API 公开接口
.requestMatchers("/api/auth/login", "/api/auth/register", "/api/auth/logout").permitAll() .requestMatchers("/api/auth/login", "/api/auth/register", "/api/auth/logout").permitAll()
.requestMatchers("/api/captcha").permitAll()
.requestMatchers("/api/files/test").permitAll() .requestMatchers("/api/files/test").permitAll()
.requestMatchers("/api/users/config").permitAll() .requestMatchers("/api/users/config").permitAll()
// WebSocket // WebSocket

View File

@@ -4,6 +4,7 @@ import com.filesystem.entity.User;
import com.filesystem.security.UserPrincipal; import com.filesystem.security.UserPrincipal;
import com.filesystem.service.UserService; import com.filesystem.service.UserService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -19,17 +20,23 @@ public class AuthController {
private UserService userService; private UserService userService;
@PostMapping("/login") @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 username = request.get("username");
String password = request.get("password"); String password = request.get("password");
String captcha = request.get("captcha");
// 验证码校验
if (!CaptchaController.verify(session, captcha)) {
return ResponseEntity.badRequest().body(Map.of("message", "验证码错误或已过期"));
}
try { try {
String token = userService.login(username, password); String token = userService.login(username, password);
User user = userService.findByUsername(username); User user = userService.findByUsername(username);
// 精确重算存储空间 // 直接读取存储空间,不重算
long storageUsed = userService.recalculateStorage(user.getId()); long storageUsed = user.getStorageUsed() != null ? user.getStorageUsed() : 0L;
long storageLimit = user.getStorageLimit(); long storageLimit = user.getStorageLimit() != null ? user.getStorageLimit() : 0L;
Map<String, Object> userData = new HashMap<>(); Map<String, Object> userData = new HashMap<>();
userData.put("id", user.getId()); 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("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
m.put("fromAvatar", fromUser.getAvatar()); m.put("fromAvatar", fromUser.getAvatar());
m.put("fromSignature", fromUser.getSignature()); m.put("fromSignature", fromUser.getSignature());
m.put("fromPhone", fromUser.getPhone());
m.put("fromEmail", fromUser.getEmail());
} }
return m; return m;
}).collect(Collectors.toList()); }).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("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
messageData.put("fromAvatar", fromUser.getAvatar()); messageData.put("fromAvatar", fromUser.getAvatar());
messageData.put("fromSignature", fromUser.getSignature()); 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); 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 register = (data) => request.post('/auth/register', data)
export const getCurrentUser = () => request.get('/auth/info') export const getCurrentUser = () => request.get('/auth/info')
export const getCaptcha = () => request.get('/captcha')

View File

@@ -84,7 +84,7 @@
</div> </div>
<template v-else> <template v-else>
<div v-for="msg in currentMessages" :key="msg.id" class="message-wrapper" :class="{ self: msg.isSelf }"> <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> <template #reference>
<el-avatar <el-avatar
:size="30" :size="30"
@@ -104,9 +104,17 @@
<span class="user-info-label">昵称:</span> <span class="user-info-label">昵称:</span>
<span class="user-info-value">{{ msg.isSelf ? (currentUserNickname || currentUserName) : (msg.fromNickname || '未知') }}</span> <span class="user-info-value">{{ msg.isSelf ? (currentUserNickname || currentUserName) : (msg.fromNickname || '未知') }}</span>
</div> </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> <span class="user-info-label">签名:</span>
<div class="user-info-signature"> <div class="user-info-signature-box">
{{ msg.isSelf ? (userStore.signature || '暂无') : (msg.fromSignature || '暂无') }} {{ msg.isSelf ? (userStore.signature || '暂无') : (msg.fromSignature || '暂无') }}
</div> </div>
</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 { 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; } .uploading-mini .el-icon { font-size: 14px; }
.user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; } .user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
.user-info-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; } .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; } .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; } .user-info-value { color: #606266; word-break: break-all; flex: 1; }
.signature-row { flex-direction: column; align-items: flex-start; } .user-info-signature-box {
.user-info-signature { color: #606266; font-size: 12px; line-height: 1.4; word-break: break-all; margin-top: 4px; } 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 { 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; } .chat-empty p { margin: 0; font-size: 14px; }

View File

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

View File

@@ -19,48 +19,90 @@
<template #prefix><el-icon><Lock /></el-icon></template> <template #prefix><el-icon><Lock /></el-icon></template>
</el-input> </el-input>
</el-form-item> </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-form-item>
<el-button <el-button
type="primary" type="primary"
:loading="loading" :loading="loading"
:disabled="!isFormComplete"
native-type="submit" native-type="submit"
style="width: 100%" style="width: 100%"
> >
<template v-if="loading">
<span>登录中...</span>
</template>
<template v-else>
<el-icon><Right /></el-icon> <el-icon><Right /></el-icon>
<span style="margin-left: 4px">登录</span> <span style="margin-left: 4px">登录</span>
</template>
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</template> </template>
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { User, Lock, Right } from '@element-plus/icons-vue' import { User, Lock, Right, Key } from '@element-plus/icons-vue'
import { login } from '@/api/auth' import { login, getCaptcha } from '@/api/auth'
const emit = defineEmits(['success']) const emit = defineEmits(['success'])
const formRef = ref(null) const formRef = ref(null)
const loading = ref(false) 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 = { const rules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }], 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 handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false) if (!isFormComplete.value || loading.value) return
if (!valid || loading.value) return
loading.value = true loading.value = true
try { try {
const res = await login(form) const res = await login(form)
// res.data = { token, user } ElMessage.success('登录成功,正在跳转...')
emit('success', res.data) emit('success', res.data)
} catch (e) { } catch (e) {
// 登录失败刷新验证码
refreshCaptcha()
form.captcha = ''
if (!e.response) { if (!e.response) {
ElMessage.error('后端服务不可用,请稍后重试') ElMessage.error('后端服务不可用,请稍后重试')
} else if (e.response.status >= 500) { } else if (e.response.status >= 500) {
@@ -72,13 +114,36 @@ const handleLogin = async () => {
loading.value = false loading.value = false
} }
} }
onMounted(() => {
refreshCaptcha()
})
</script> </script>
<style scoped> <style scoped>
.form-tips { .captcha-row {
text-align: center; display: flex;
color: #c0c4cc; gap: 12px;
font-size: 12px; width: 100%;
margin-top: -8px; 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> </style>