接下来完成一个完整的用户认证系统,从后端实体设计、接口实现,到前端页面和前后端对接,最后引入 JWT 实现无状态认证。
1. 后端代码
1.1 实体设计
1.1.1 User 实体
src/main/java/com/albertstack/aichat/entity/User.java
package com.albertstack.aichat.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Table("USERS")
public record User(
@Id Long id,
String username,
String password,
LocalDateTime createdAt
) {
public static User create(String username, String password) {
return new User(null, username, password, LocalDateTime.now());
}
}1.1.2 UserRepository
src/main/java/com/albertstack/aichat/repository/UserRepository.java
package com.albertstack.aichat.repository;
import com.albertstack.aichat.entity.User;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Mono;
public interface UserRepository extends ReactiveCrudRepository<User, Long> {
Mono<User> findByUsername(String username);
}1.1.3 Repository 测试
src/test/java/com/albertstack/aichat/repository/UserRepositoryTest.java
package com.albertstack.aichat.repository;
import com.albertstack.aichat.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.r2dbc.core.DatabaseClient;
import reactor.test.StepVerifier;
@Slf4j
@SpringBootTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private DatabaseClient databaseClient;
@BeforeEach
void setUp() {
// 截断表:先关闭外键检查,截断后恢复(H2 语法)
databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();
}
@Test
void save_shouldPersistUser() {
User user = User.create("Albert", "ps123456");
StepVerifier.create(userRepository.save(user))
.expectNextMatches(saved -> {
log.info("save_shouldPersistUser => id={}, username={}", saved.id(), saved.username());
return saved.id() != null && saved.username().equals("Albert");
})
.verifyComplete();
}
@Test
void findByUsername_shouldReturnUser() {
User user = User.create("Albert", "ps123456");
userRepository.save(user).block();
StepVerifier.create(userRepository.findByUsername("Albert"))
.expectNextMatches(found -> {
log.info("findByUsername_shouldReturnUser => id={}, username={}", found.id(), found.username());
return found.username().equals("Albert");
})
.verifyComplete();
}
@Test
void findByUsername_shouldReturnEmptyForNonExistent() {
StepVerifier.create(
userRepository.findByUsername("Albert")
.doOnSuccess(found -> {
if (found == null) {
log.info("findByUsername_shouldReturnEmptyForNonExistent => user 'Albert' not found");
} else {
log.info("findByUsername_shouldReturnEmptyForNonExistent => id={}, username={}", found.id(), found.username());
}
})
)
.verifyComplete();
}
}1.2 接口设计
1.2.1 DTO 设计
src/main/java/com/albertstack/aichat/dto/RegisterRequest.java
package com.albertstack.aichat.dto;
public record RegisterRequest(String username, String password) {}src/main/java/com/albertstack/aichat/dto/LoginRequest.java
package com.albertstack.aichat.dto;
public record LoginRequest(String username, String password) {}src/main/java/com/albertstack/aichat/dto/AuthResponse.java
package com.albertstack.aichat.dto;
public record AuthResponse(String token, String username) {}1.2.2 UserService
src/main/java/com/albertstack/aichat/service/UserService.java
package com.albertstack.aichat.service;
import com.albertstack.aichat.dto.AuthResponse;
import com.albertstack.aichat.dto.LoginRequest;
import com.albertstack.aichat.dto.RegisterRequest;
import com.albertstack.aichat.entity.User;
import com.albertstack.aichat.repository.UserRepository;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Mono<AuthResponse> register(RegisterRequest request) {
return userRepository.findByUsername(request.username())
.flatMap(existing -> Mono.<AuthResponse>error(
new RuntimeException("用户名已存在")))
.switchIfEmpty(Mono.defer(() -> {
// 先用明文密码,后续引入 BCrypt 加密
User user = User.create(request.username(), request.password());
return userRepository.save(user)
.map(saved -> new AuthResponse("temp-token", saved.username()));
}));
}
public Mono<AuthResponse> login(LoginRequest request) {
return userRepository.findByUsername(request.username())
.filter(user -> user.password().equals(request.password()))
.map(user -> new AuthResponse("temp-token", user.username()))
.switchIfEmpty(Mono.error(new RuntimeException("用户名或密码错误")));
}
}1.2.3 AuthController
src/main/java/com/albertstack/aichat/controller/AuthController.java
package com.albertstack.aichat.controller;
import com.albertstack.aichat.common.ApiResponse;
import com.albertstack.aichat.dto.AuthResponse;
import com.albertstack.aichat.dto.LoginRequest;
import com.albertstack.aichat.dto.RegisterRequest;
import com.albertstack.aichat.service.UserService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
public AuthController(UserService userService) {
this.userService = userService;
}
@PostMapping("/register")
public Mono<ApiResponse<AuthResponse>> register(@RequestBody RegisterRequest request) {
return userService.register(request).map(ApiResponse::success);
}
@PostMapping("/login")
public Mono<ApiResponse<AuthResponse>> login(@RequestBody LoginRequest request) {
return userService.login(request).map(ApiResponse::success);
}
}Controller 中不需要捕获异常,系统异常统一由 GlobalExceptionHandler 处理。
1.2.4 GlobalExceptionHandler
src/main/java/com/albertstack/aichat/config/GlobalExceptionHandler.java
package com.albertstack.aichat.config;
import com.albertstack.aichat.common.ApiResponse;
import com.albertstack.aichat.common.ResultCode;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import reactor.core.publisher.Mono;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Mono<ApiResponse<Void>> handleRuntimeException(RuntimeException e) {
return Mono.just(ApiResponse.error(ResultCode.BAD_REQUEST, e.getMessage()));
}
}1.3 接口测试
使用前面创建的 ApiTest 工具类。
src/test/java/com/albertstack/aichat/controller/AuthControllerTest.java
package com.albertstack.aichat.controller;
import com.albertstack.aichat.common.ResultCode;
import com.albertstack.aichat.dto.LoginRequest;
import com.albertstack.aichat.dto.RegisterRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.test.web.reactive.server.WebTestClient;
import static com.albertstack.aichat.util.ApiTest.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class AuthControllerTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private DatabaseClient databaseClient;
@BeforeEach
void setUp() {
databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();
}
@Test
void register_shouldCreateUser() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Albert", "ps123456"))
.verify(
eq("code", ResultCode.SUCCESS.getCode()),
eq("data.username", "Albert"),
notNull("data.token")
);
}
@Test
void register_duplicateUsername_shouldFail() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Albert", "ps123456"))
.verify(eq("code", ResultCode.SUCCESS.getCode()));
of(webTestClient).post("/api/auth/register", new RegisterRequest("Albert", "password"))
.expectStatus(400)
.verify(
eq("code", ResultCode.BAD_REQUEST.getCode()),
eq("message", "用户名已存在")
);
}
@Test
void login_withCorrectPassword_shouldSucceed() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Macy", "ps123456"))
.verify(eq("code", ResultCode.SUCCESS.getCode()));
of(webTestClient).post("/api/auth/login", new LoginRequest("Macy", "ps123456"))
.verify(
eq("code", ResultCode.SUCCESS.getCode()),
eq("data.username", "Macy"),
notNull("data.token")
);
}
@Test
void login_withWrongPassword_shouldFail() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Macy", "ps123456"))
.verify(eq("code", ResultCode.SUCCESS.getCode()));
of(webTestClient).post("/api/auth/login", new LoginRequest("Macy", "password"))
.expectStatus(400)
.verify(
eq("code", ResultCode.BAD_REQUEST.getCode()),
eq("message", "用户名或密码错误")
);
}
@Test
void login_nonExistentUser_shouldFail() {
of(webTestClient).post("/api/auth/login", new LoginRequest("nobody", "ps123456"))
.expectStatus(400)
.verify(eq("code", ResultCode.BAD_REQUEST.getCode()));
}
}这几个测试用例应该全部通过,此时注册登录的核心逻辑已经可用,但还没有真正的安全机制,任何人都能直接访问所有接口。
2. 前端代码
2.1 Auth API
src/api/auth.ts
import api from './index'
export interface AuthResponse {
token: string
username: string
}
export function loginApi(username: string, password: string) {
return api.post<AuthResponse>('/auth/login', { username, password })
}
export function registerApi(username: string, password: string) {
return api.post<AuthResponse>('/auth/register', { username, password })
}2.2 Auth Store
src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, registerApi } from '../api/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
const isLoggedIn = computed(() => !!token.value)
/** 登录:调用接口并写入凭证 */
async function login(user: string, pass: string) {
const { data } = await loginApi(user, pass)
setAuth(data.token, data.username)
}
/** 注册:只调用接口,不自动登录(由调用方决定时机) */
async function register(user: string, pass: string) {
const { data } = await registerApi(user, pass)
return data
}
/** 写入凭证(注册确认后或其他场景使用) */
function setAuth(t: string, u: string) {
token.value = t
username.value = u
localStorage.setItem('token', t)
localStorage.setItem('username', u)
}
function logout() {
token.value = ''
username.value = ''
localStorage.removeItem('token')
localStorage.removeItem('username')
}
return { token, username, isLoggedIn, login, register, setAuth, logout }
})2.3 RegisterView
src/views/RegisterView.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import type { AuthResponse } from '../api/auth'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const error = ref('')
// 注册成功后暂存响应数据,等用户确认后再登录
const showSuccessModal = ref(false)
const pendingAuth = ref<AuthResponse | null>(null)
async function handleRegister() {
error.value = ''
if (password.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
return
}
if (password.value.length < 6) {
error.value = '密码长度至少 6 位'
return
}
loading.value = true
try {
const data = await authStore.register(username.value, password.value)
// 注册成功,弹窗确认
pendingAuth.value = data
showSuccessModal.value = true
} catch (e: any) {
error.value = e instanceof Error ? e.message : '注册失败'
} finally {
loading.value = false
}
}
function confirmAndLogin() {
if (pendingAuth.value) {
// 用户确认后,写入凭证并跳转
authStore.setAuth(pendingAuth.value.token, pendingAuth.value.username)
router.push('/')
}
}
function goToLogin() {
router.push('/login')
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="card bg-base-100 w-96 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center text-2xl mb-4">创建账号</h2>
<div v-if="error" class="alert alert-error mb-4">
<span>{{ error }}</span>
</div>
<form @submit.prevent="handleRegister">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">用户名</span>
</label>
<input
v-model="username"
type="text"
placeholder="请输入用户名"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">密码</span>
</label>
<input
v-model="password"
type="password"
placeholder="请输入密码"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control mb-6">
<label class="label">
<span class="label-text">确认密码</span>
</label>
<input
v-model="confirmPassword"
type="password"
placeholder="请再次输入密码"
class="input input-bordered w-full"
required
/>
</div>
<button
type="submit"
class="btn btn-primary w-full"
:disabled="loading"
>
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
注册
</button>
</form>
<div class="divider">或</div>
<button class="btn btn-outline w-full" @click="goToLogin">
已有账号?去登录
</button>
</div>
</div>
<!-- 注册成功确认弹窗 -->
<dialog :class="['modal', { 'modal-open': showSuccessModal }]">
<div class="modal-box">
<h3 class="text-lg font-bold">注册成功</h3>
<p class="py-4">
账号 <span class="font-semibold text-primary">{{ pendingAuth?.username }}</span> 已创建成功,点击确认自动登录。
</p>
<div class="modal-action">
<button class="btn btn-primary" @click="confirmAndLogin">
确认登录
</button>
</div>
</div>
</dialog>
</div>
</template>2.4 LoginView
src/views/LoginView.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function handleLogin() {
error.value = ''
loading.value = true
try {
await authStore.login(username.value, password.value)
router.push('/')
} catch (e: any) {
error.value = e instanceof Error ? e.message : '登录失败'
} finally {
loading.value = false
}
}
function goToRegister() {
router.push('/register')
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="card bg-base-100 w-96 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center text-2xl mb-4">AI Chat</h2>
<!-- 错误提示 -->
<div v-if="error" class="alert alert-error mb-4">
<span>{{ error }}</span>
</div>
<form @submit.prevent="handleLogin">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">用户名</span>
</label>
<input
v-model="username"
type="text"
placeholder="请输入用户名"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control mb-6">
<label class="label">
<span class="label-text">密码</span>
</label>
<input
v-model="password"
type="password"
placeholder="请输入密码"
class="input input-bordered w-full"
required
/>
</div>
<button
type="submit"
class="btn btn-primary w-full"
:disabled="loading"
>
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
登录
</button>
</form>
<div class="divider">或</div>
<button class="btn btn-outline w-full" @click="goToRegister">
创建新账号
</button>
</div>
</div>
</div>
</template>2.5 更新 Router
添加注册页面路由,同时添加路由守卫:
src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import LoginView from '../views/LoginView.vue'
import RegisterView from '../views/RegisterView.vue'
import ChatView from '../views/ChatView.vue'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: LoginView,
},
{
path: '/register',
name: 'Register',
component: RegisterView,
},
{
path: '/',
name: 'Chat',
component: ChatView,
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:未登录时跳转到登录页
router.beforeEach((to) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
return { name: 'Login' }
}
// 已登录时访问登录/注册页,直接跳转到首页
if ((to.name === 'Login' || to.name === 'Register') && token) {
return { name: 'Chat' }
}
})
export default router2.6 页面测试
启动前后端项目,在浏览器中测试:
- 访问 http://localhost:5173 ,应该会被重定向到
login - 点击「创建新账号」,跳转到注册页
- 输入用户名和密码,注册成功后自动跳转到聊天页
- 打开浏览器 DevTools -> Application -> LocalStorage,应能看到
token和username
现在已经基本实现了登录注册,接下来将引入 JWT 与 Spring Security,完成真正的认证和鉴权机制。
3. JWT 接入
3.1 引入依赖
在后端 pom.xml 的 <dependencies> 中添加:
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<!-- 安全测试工具 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>引入说明:
- Spring Security:Spring 的安全框架,提供认证和授权机制。在 WebFlux 中,它通过
SecurityWebFilterChain实现请求过滤。 - JJWT:JWT 的 Java 实现库,一共有三个模块:
api(接口)、impl(实现)、jackson(JSON 序列化)。运行时只需要impl和jackson。
3.2 添加配置
在 application.yaml 中添加:
# JWT 配置
jwt:
secret: albert-stack-ai-chat-jwt-secret-key-2026 # 至少 32 字符(256 bits)
expiration: 86400000 # 24 小时(毫秒)3.3 JwtUtil
src/main/java/com/albertstack/aichat/util/JwtUtil.java
package com.albertstack.aichat.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JwtUtil {
private final SecretKey key;
private final long expiration;
public JwtUtil(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration
) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expiration = expiration;
}
// 生成 Token,payload 中携带 userId
public String generateToken(Long userId, String username) {
return Jwts.builder()
.subject(userId.toString())
.claim("username", username)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(key)
.compact();
}
// 从 Token 中提取 userId
public Long getUserId(String token) {
Claims claims = parseClaims(token);
return Long.parseLong(claims.getSubject());
}
// 验证 Token 是否有效
public boolean isValid(String token) {
try {
parseClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}3.4 JwtAuthenticationFilter
src/main/java/com/albertstack/aichat/config/JwtAuthenticationFilter.java
package com.albertstack.aichat.config;
import com.albertstack.aichat.util.JwtUtil;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
public class JwtAuthenticationFilter implements WebFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.isValid(token)) {
Long userId = jwtUtil.getUserId(token);
// 将 userId 作为 principal 存入安全上下文
var auth = new UsernamePasswordAuthenticationToken(
userId, null, List.of()
);
return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth));
}
}
return chain.filter(exchange);
}
}这个过滤器的工作流程:
- 从请求头中提取
Authorization: Bearer <token> - 验证 token 有效性
- 提取 userId,放入
ReactiveSecurityContextHolder - 后续的 Controller / Service 可以通过安全上下文获取当前用户 ID
3.5 SecurityConfig
src/main/java/com/albertstack/aichat/config/SecurityConfig.java
package com.albertstack.aichat.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.authorizeExchange(exchanges -> exchanges
// 公开接口:注册、登录、健康检查
.pathMatchers("/api/auth/**", "/api/ping").permitAll()
// 其他所有 /api/** 接口需要认证
.pathMatchers("/api/**").authenticated()
// 非 API 路径放行(前端静态资源等)
.anyExchange().permitAll()
)
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}3.6 更新 UserService
引入 JWT 后,需要修改 UserService:密码改为 BCrypt 加密,token 改为真正的 JWT。
package com.albertstack.aichat.service;
import com.albertstack.aichat.dto.AuthResponse;
import com.albertstack.aichat.dto.LoginRequest;
import com.albertstack.aichat.dto.RegisterRequest;
import com.albertstack.aichat.entity.User;
import com.albertstack.aichat.repository.UserRepository;
import com.albertstack.aichat.util.JwtUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
}
public Mono<AuthResponse> register(RegisterRequest request) {
return userRepository.findByUsername(request.username())
.flatMap(existing -> Mono.<AuthResponse>error(
new RuntimeException("用户名已存在")))
.switchIfEmpty(Mono.defer(() -> {
// 加密密码
String encodedPassword = passwordEncoder.encode(request.password());
User user = User.create(request.username(), encodedPassword);
return userRepository.save(user)
.map(saved -> {
// 生成 jwt token
String token = jwtUtil.generateToken(saved.id(), saved.username());
return new AuthResponse(token, saved.username());
});
}));
}
public Mono<AuthResponse> login(LoginRequest request) {
return userRepository.findByUsername(request.username())
.filter(user -> passwordEncoder.matches(request.password(), user.password()))
.map(user -> {
// 生成 jwt token
String token = jwtUtil.generateToken(user.id(), user.username());
return new AuthResponse(token, user.username());
})
.switchIfEmpty(Mono.error(new RuntimeException("用户名或密码错误")));
}
}改动点:
- 注入
PasswordEncoder和JwtUtil - 注册时用
passwordEncoder.encode()加密密码 - 登录时用
passwordEncoder.matches()比对密码 - Token 改为
jwtUtil.generateToken()生成真正的 JWT
3.7 SecurityUtils
创建一个工具类,方便后续在 Service 中获取当前登录用户的 ID。
src/main/java/com/albertstack/aichat/util/SecurityUtils.java
package com.albertstack.aichat.util;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import reactor.core.publisher.Mono;
import java.util.Objects;
public class SecurityUtils {
private SecurityUtils() {}
/**
* 从响应式安全上下文中提取当前用户 ID
* @return 包含当前用户 ID 的 Mono,如果没有认证信息则 Mono 为空
*/
public static Mono<Long> getCurrentUserId() {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> (Long) Objects.requireNonNull(Objects.requireNonNull(ctx.getAuthentication()).getPrincipal()));
}
}3.8 测试用例
3.8.1 新增 JwtUtilTest
src/test/java/com/albertstack/aichat/util/JwtUtilTest.java
package com.albertstack.aichat.util;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
@Slf4j
@SpringBootTest
class JwtUtilTest {
@Autowired
private JwtUtil jwtUtil;
@Test
void generateToken_shouldCreateValidToken() {
String token = jwtUtil.generateToken(1L, "Albert");
log.info("generateToken => {}", token);
assertNotNull(token);
int parts = token.split("\\.").length;
log.info(" ✅ JWT 结构 = {} 段(Header.Payload.Signature)", parts);
assertEquals(3, parts);
}
@Test
void getUserId_shouldExtractCorrectId() {
String token = jwtUtil.generateToken(42L, "Macy");
Long userId = jwtUtil.getUserId(token);
log.info("getUserId => {}", userId);
log.info(" ✅ userId = {}", userId);
assertEquals(42L, userId);
}
@Test
void isValid_shouldReturnTrueForValidToken() {
String token = jwtUtil.generateToken(1L, "Albert");
boolean valid = jwtUtil.isValid(token);
log.info("isValid(validToken) => {}", valid);
log.info(" ✅ 合法 token 验证通过");
assertTrue(valid);
}
@Test
void isValid_shouldReturnFalseForTamperedToken() {
String token = jwtUtil.generateToken(1L, "Albert");
String tampered = token + "tampered";
boolean valid = jwtUtil.isValid(tampered);
log.info("isValid(tamperedToken) => {}", valid);
log.info(" ✅ 篡改 token 验证失败(符合预期)");
assertFalse(valid);
}
@Test
void isValid_shouldReturnFalseForRandomString() {
boolean valid = jwtUtil.isValid("not-a-jwt-token");
log.info("isValid(randomString) => {}", valid);
log.info(" ✅ 随机字符串验证失败(符合预期)");
assertFalse(valid);
}
}3.8.2 更新 AuthControllerTest
引入 Spring Security 后,之前的测试需要适配。因为现在 /api/auth/** 以外的接口都需要认证了。
更新 AuthControllerTest.java(认证接口本身是公开的,所以测试逻辑不变,但需要确保 Security 配置正确):
src/test/java/com/albertstack/aichat/controller/AuthControllerTest.java
package com.albertstack.aichat.controller;
import com.albertstack.aichat.common.ResultCode;
import com.albertstack.aichat.dto.LoginRequest;
import com.albertstack.aichat.dto.RegisterRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.test.web.reactive.server.WebTestClient;
import static com.albertstack.aichat.util.ApiTest.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class AuthControllerTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private DatabaseClient databaseClient;
@BeforeEach
void setUp() {
databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();
}
@Test
void register_shouldReturnJwtToken() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Albert", "ps123456"))
.verify(
eq("code", ResultCode.SUCCESS.getCode()),
eq("data.username", "Albert"),
notNull("data.token")
);
}
@Test
void register_duplicateUsername_shouldFail() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Albert", "ps123456"))
.verify(eq("code", ResultCode.SUCCESS.getCode()));
of(webTestClient).post("/api/auth/register", new RegisterRequest("Albert", "password"))
.expectStatus(400)
.verify(
eq("code", ResultCode.BAD_REQUEST.getCode()),
eq("message", "用户名已存在")
);
}
@Test
void login_shouldReturnJwtToken() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Macy", "ps123456"))
.verify(eq("code", ResultCode.SUCCESS.getCode()));
of(webTestClient).post("/api/auth/login", new LoginRequest("Macy", "ps123456"))
.verify(
eq("code", ResultCode.SUCCESS.getCode()),
eq("data.username", "Macy"),
notNull("data.token")
);
}
@Test
void login_withWrongPassword_shouldFail() {
of(webTestClient).post("/api/auth/register", new RegisterRequest("Macy", "ps123456"))
.verify(eq("code", ResultCode.SUCCESS.getCode()));
of(webTestClient).post("/api/auth/login", new LoginRequest("Macy", "wrongpassword"))
.expectStatus(400)
.verify(
eq("code", ResultCode.BAD_REQUEST.getCode()),
eq("message", "用户名或密码错误")
);
}
@Test
void protectedEndpoint_withoutToken_shouldReturn401() {
of(webTestClient).get("/api/topics")
.expectStatus(401)
.verify();
}
}4. 前端适配
前端不需要改动代码!之前的设计已经预留好了 JWT 的接入点:
- Axios 请求拦截器:自动从
localStorage读取 token 并添加到Authorization: Bearer <token>请求头,之前存的是temp-token,现在变成真正的 JWT,拦截器逻辑完全不用改 - Axios 响应拦截器:401 时清除 token 并跳转登录页,JWT 过期或无效时自动触发
- Auth Store 的
setAuth:登录和注册确认后都通过它把 token 写入localStorage,token 从temp-token变成了eyJhbG...格式的 JWT 字符串,但存取逻辑完全一样
5. 测试验证
- 启动前后端项目
- 浏览器打开
http://localhost:5173 - 注册一个新账号
- 注册成功后自动跳转到聊天页面
- 打开 DevTools -> Application -> LocalStorage,可以看到一个真正的 JWT token(以
eyJ开头的长字符串) - 删除 LocalStorage 中的 token,刷新页面,应被跳转到登录页
6. 小结
完成了完整的认证系统,覆盖了从数据库到前端的全栈流程:
| 层 | 内容 |
|---|---|
| 数据库 | User 实体,schema.sql 建表 |
| 后端 Service | 注册(BCrypt 密码加密)、登录(密码验证)、JWT 生成 |
| 后端安全 | JwtAuthenticationFilter、SecurityConfig、SecurityUtils |
| 后端测试 | UserRepositoryTest、AuthControllerTest、JwtUtilTest |
| 前端页面 | LoginView(登录)、RegisterView(注册 + 成功确认弹窗) |
| 前端状态 | Auth Store(token 管理、setAuth 凭证写入)、路由守卫 |
新引入的技术:
| 技术 | 作用 |
|---|---|
| Spring Security | 请求级别的认证和授权控制 |
| BCryptPasswordEncoder | 密码加密(不可逆哈希) |
| JJWT(0.13.0) | JWT Token 的生成、解析、验证 |
| ReactiveSecurityContextHolder | 在响应式上下文中传递认证信息 |
| DaisyUI Modal | 注册成功确认弹窗(dialog + modal-open) |
接下来介绍 Spring AI 核心概念,通过测试用例理解 Message、Prompt、ChatModel、ChatClient 等组件的用法。