增加登录验证码功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
130
src/main/java/com/filesystem/controller/CaptchaController.java
Normal file
130
src/main/java/com/filesystem/controller/CaptchaController.java
Normal 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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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%"
|
||||
>
|
||||
<el-icon><Right /></el-icon>
|
||||
<span style="margin-left: 4px">登录</span>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user