Skip to content

认证授权

CmdAdmin 使用 JWT(JSON Web Token)+ Spring Security 实现无状态认证授权机制。

认证流程

┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐
│  用户    │────▶│  登录   │────▶│  验证   │────▶│  颁发   │
│         │     │         │     │         │     │  Token  │
└─────────┘     └─────────┘     └─────────┘     └─────────┘
     │                                               │
     │                                               ▼
     │                                          ┌─────────┐
     │                                          │  Redis  │
     │                                          │  存储   │
     │                                          └─────────┘
     │                                               │
     │                                               ▼
     │     ┌─────────┐     ┌─────────┐     ┌─────────┐
     └────▶│  请求   │────▶│  验证   │────▶│  访问   │
           │  API    │     │  Token  │     │  资源   │
           └─────────┘     └─────────┘     └─────────┘

登录认证

登录流程

  1. 获取验证码
java
@GetMapping("/captcha")
public Result<CaptchaVO> getCaptcha() {
    // 生成算术验证码
    String code = generateMathCaptcha();
    String uuid = UUID.randomUUID().toString();
    
    // 存储到 Redis,5分钟过期
    redisTemplate.opsForValue().set(
        "captcha:" + uuid, 
        code, 
        5, 
        TimeUnit.MINUTES
    );
    
    return Result.ok(new CaptchaVO(uuid, codeImage));
}
  1. 用户登录
java
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody @Validated LoginDTO dto) {
    // 1. 验证验证码
    String captchaKey = "captcha:" + dto.getUuid();
    String captcha = redisTemplate.opsForValue().get(captchaKey);
    if (captcha == null || !captcha.equals(dto.getCode())) {
        return Result.error("验证码错误或已过期");
    }
    // 删除已使用的验证码
    redisTemplate.delete(captchaKey);
    
    // 2. 验证用户名密码
    UserDetails userDetails = userDetailsService.loadUserByUsername(dto.getUsername());
    if (!passwordEncoder.matches(dto.getPassword(), userDetails.getPassword())) {
        // 记录登录失败日志
        recordLoginLog(dto.getUsername(), false, "密码错误");
        return Result.error("用户名或密码错误");
    }
    
    // 3. 生成 JWT Token
    String token = jwtTokenUtil.generateToken(userDetails);
    String refreshToken = jwtTokenUtil.generateRefreshToken(userDetails);
    
    // 4. 存储到 Redis
    redisTemplate.opsForValue().set(
        "login_token:" + token,
        userDetails,
        jwtTokenUtil.getExpiration(),
        TimeUnit.MILLISECONDS
    );
    
    // 5. 记录登录成功日志
    recordLoginLog(dto.getUsername(), true, "登录成功");
    
    return Result.ok(new LoginVO(token, refreshToken));
}

JWT Token

Token 结构

java
@Component
public class JwtTokenUtil {
    
    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.expiration:86400000}")
    private Long expiration; // 24小时
    
    @Value("${jwt.refresh-expiration:604800000}")
    private Long refreshExpiration; // 7天
    
    /**
     * 生成 Token
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", userDetails.getUsername());
        claims.put("roles", userDetails.getAuthorities());
        
        return Jwts.builder()
            .setClaims(claims)
            .setSubject(userDetails.getUsername())
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }
    
    /**
     * 验证 Token
     */
    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return username.equals(userDetails.getUsername()) 
            && !isTokenExpired(token);
    }
    
    /**
     * 刷新 Token
     */
    public String refreshToken(String token) {
        final Date createdDate = new Date();
        final Date expirationDate = new Date(createdDate.getTime() + expiration);
        
        return Jwts.builder()
            .setClaims(getClaimsFromToken(token))
            .setIssuedAt(createdDate)
            .setExpiration(expirationDate)
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }
}

Token 刷新

java
@PostMapping("/refresh")
public Result<LoginVO> refreshToken(@RequestHeader("Authorization") String token) {
    // 去除 Bearer 前缀
    token = token.replace("Bearer ", "");
    
    // 验证刷新令牌
    if (!jwtTokenUtil.canTokenBeRefreshed(token)) {
        return Result.error("令牌已过期,请重新登录");
    }
    
    // 生成新令牌
    String newToken = jwtTokenUtil.refreshToken(token);
    String newRefreshToken = jwtTokenUtil.generateRefreshToken(
        jwtTokenUtil.getUserDetailsFromToken(token)
    );
    
    // 更新 Redis
    redisTemplate.delete("login_token:" + token);
    redisTemplate.opsForValue().set(
        "login_token:" + newToken,
        userDetails,
        jwtTokenUtil.getExpiration(),
        TimeUnit.MILLISECONDS
    );
    
    return Result.ok(new LoginVO(newToken, newRefreshToken));
}

认证过滤器

JWT 认证过滤器

java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
        
        // 1. 获取请求头中的 Token
        final String requestTokenHeader = request.getHeader("Authorization");
        
        String username = null;
        String jwtToken = null;
        
        // 2. 解析 Token
        if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
            jwtToken = requestTokenHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(jwtToken);
            } catch (ExpiredJwtException e) {
                logger.warn("JWT Token 已过期");
            } catch (Exception e) {
                logger.error("JWT Token 解析失败", e);
            }
        }
        
        // 3. 验证 Token
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
            
            // 从 Redis 验证 Token 是否有效
            String redisKey = "login_token:" + jwtToken;
            Object storedToken = redisTemplate.opsForValue().get(redisKey);
            
            if (storedToken != null && jwtTokenUtil.validateToken(jwtToken, userDetails)) {
                // 设置认证信息
                UsernamePasswordAuthenticationToken authToken = 
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        
        chain.doFilter(request, response);
    }
}

Spring Security 配置

java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    
    @Autowired
    private JwtAccessDeniedHandler jwtAccessDeniedHandler;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 禁用 CSRF(使用 JWT 不需要)
            .csrf(csrf -> csrf.disable())
            
            // 配置会话管理(无状态)
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            
            // 配置授权规则
            .authorizeHttpRequests(auth -> auth
                // 允许匿名访问的路径
                .requestMatchers("/auth/**").permitAll()
                .requestMatchers("/actuator/**").permitAll()
                // 其他请求需要认证
                .anyRequest().authenticated()
            )
            
            // 添加 JWT 过滤器
            .addFilterBefore(jwtAuthenticationFilter, 
                UsernamePasswordAuthenticationFilter.class)
            
            // 配置异常处理
            .exceptionHandling(exception -> exception
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
            );
        
        return http.build();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

登录日志

日志记录

java
@Component
public class LoginLogAspect {
    
    @Autowired
    private SysLoginLogService loginLogService;
    
    @AfterReturning("@annotation(loginLog)")
    public void doAfterReturning(JoinPoint joinPoint, LoginLog loginLog) {
        // 获取请求信息
        HttpServletRequest request = ((ServletRequestAttributes) 
            RequestContextHolder.getRequestAttributes()).getRequest();
        
        SysLoginLog log = new SysLoginLog();
        log.setUsername(getUsername(joinPoint));
        log.setIp(getIpAddress(request));
        log.setLocation(getLocation(log.getIp()));
        log.setBrowser(getBrowser(request));
        log.setOs(getOs(request));
        log.setStatus("0"); // 成功
        log.setMsg(loginLog.value());
        log.setLoginTime(LocalDateTime.now());
        
        loginLogService.save(log);
    }
    
    private String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty()) {
            ip = request.getRemoteAddr();
        }
        return ip.split(",")[0].trim();
    }
    
    private String getBrowser(HttpServletRequest request) {
        String userAgent = request.getHeader("User-Agent");
        // 解析浏览器信息...
        return parseBrowser(userAgent);
    }
}

前端认证

Pinia Store

typescript
// stores/auth.ts
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    token: localStorage.getItem('token') || '',
    refreshToken: localStorage.getItem('refreshToken') || '',
    userInfo: null as UserInfo | null,
    permissions: [] as string[]
  }),
  
  getters: {
    isLoggedIn: (state) => !!state.token,
    hasPermission: (state) => (permission: string) => {
      return state.permissions.includes(permission)
    }
  },
  
  actions: {
    async login(loginForm: LoginForm) {
      const { data } = await loginApi(loginForm)
      this.token = data.token
      this.refreshToken = data.refreshToken
      localStorage.setItem('token', data.token)
      localStorage.setItem('refreshToken', data.refreshToken)
      
      // 获取用户信息
      await this.getUserInfo()
      
      // 加载动态路由
      await this.loadRoutes()
    },
    
    async getUserInfo() {
      const { data } = await getUserInfoApi()
      this.userInfo = data
      this.permissions = data.permissions
    },
    
    async logout() {
      await logoutApi()
      this.token = ''
      this.refreshToken = ''
      this.userInfo = null
      this.permissions = []
      localStorage.removeItem('token')
      localStorage.removeItem('refreshToken')
    },
    
    async refreshToken() {
      const { data } = await refreshTokenApi(this.refreshToken)
      this.token = data.token
      this.refreshToken = data.refreshToken
      localStorage.setItem('token', data.token)
      localStorage.setItem('refreshToken', data.refreshToken)
    }
  }
})

Axios 拦截器

typescript
// utils/request.ts
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000
})

// 请求拦截器
request.interceptors.request.use(
  config => {
    const authStore = useAuthStore()
    if (authStore.token) {
      config.headers.Authorization = `Bearer ${authStore.token}`
    }
    return config
  },
  error => Promise.reject(error)
)

// 响应拦截器
request.interceptors.response.use(
  response => response.data,
  async error => {
    const { response } = error
    
    if (response?.status === 401) {
      // Token 过期,尝试刷新
      const authStore = useAuthStore()
      try {
        await authStore.refreshToken()
        // 重试原请求
        return request(error.config)
      } catch {
        // 刷新失败,跳转到登录页
        authStore.logout()
        window.location.href = '/login'
      }
    }
    
    if (response?.status === 403) {
      ElMessage.error('没有操作权限')
    }
    
    return Promise.reject(error)
  }
)

export default request

安全配置建议

  1. 使用 HTTPS:生产环境必须使用 HTTPS
  2. Token 过期时间:Access Token 建议 24 小时,Refresh Token 建议 7 天
  3. 密码加密:使用 BCrypt 等强哈希算法
  4. 防止暴力破解:登录失败次数限制
  5. 敏感操作二次验证:如修改密码、删除数据等
  6. 定期更换密钥:JWT 密钥定期更换

基于 MIT 许可发布