5. 注册与登录

6 min

接下来完成一个完整的用户认证系统,从后端实体设计、接口实现,到前端页面和前后端对接,最后引入 JWT 实现无状态认证。

1. 后端代码

1.1 实体设计

1.1.1 User 实体

src/main/java/com/albertstack/aichat/entity/User.java

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

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

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

java
package com.albertstack.aichat.dto;

public record RegisterRequest(String username, String password) {}

src/main/java/com/albertstack/aichat/dto/LoginRequest.java

java
package com.albertstack.aichat.dto;

public record LoginRequest(String username, String password) {}

src/main/java/com/albertstack/aichat/dto/AuthResponse.java

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

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

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

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

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

typescript
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

typescript
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

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

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

typescript
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 router

2.6 页面测试

启动前后端项目,在浏览器中测试:

  1. 访问 http://localhost:5173 ,应该会被重定向到 login
  2. 点击「创建新账号」,跳转到注册页
  3. 输入用户名和密码,注册成功后自动跳转到聊天页
  4. 打开浏览器 DevTools -> Application -> LocalStorage,应能看到 tokenusername

现在已经基本实现了登录注册,接下来将引入 JWT 与 Spring Security,完成真正的认证和鉴权机制。

3. JWT 接入

3.1 引入依赖

在后端 pom.xml<dependencies> 中添加:

xml
<!-- 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 序列化)。运行时只需要 impljackson

3.2 添加配置

application.yaml 中添加:

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

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

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);
    }
}

这个过滤器的工作流程:

  1. 从请求头中提取 Authorization: Bearer <token>
  2. 验证 token 有效性
  3. 提取 userId,放入 ReactiveSecurityContextHolder
  4. 后续的 Controller / Service 可以通过安全上下文获取当前用户 ID

3.5 SecurityConfig

src/main/java/com/albertstack/aichat/config/SecurityConfig.java

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。

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 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("用户名或密码错误")));
    }
}

改动点:

  • 注入 PasswordEncoderJwtUtil
  • 注册时用 passwordEncoder.encode() 加密密码
  • 登录时用 passwordEncoder.matches() 比对密码
  • Token 改为 jwtUtil.generateToken() 生成真正的 JWT

3.7 SecurityUtils

创建一个工具类,方便后续在 Service 中获取当前登录用户的 ID。

src/main/java/com/albertstack/aichat/util/SecurityUtils.java

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

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

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. 测试验证

  1. 启动前后端项目
  2. 浏览器打开 http://localhost:5173
  3. 注册一个新账号
  4. 注册成功后自动跳转到聊天页面
  5. 打开 DevTools -> Application -> LocalStorage,可以看到一个真正的 JWT token(以 eyJ 开头的长字符串)
  6. 删除 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 等组件的用法。