认证授权
CmdAdmin 使用 JWT(JSON Web Token)+ Spring Security 实现无状态认证授权机制。
认证流程
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 用户 │────▶│ 登录 │────▶│ 验证 │────▶│ 颁发 │
│ │ │ │ │ │ │ Token │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │
│ ▼
│ ┌─────────┐
│ │ Redis │
│ │ 存储 │
│ └─────────┘
│ │
│ ▼
│ ┌─────────┐ ┌─────────┐ ┌─────────┐
└────▶│ 请求 │────▶│ 验证 │────▶│ 访问 │
│ API │ │ Token │ │ 资源 │
└─────────┘ └─────────┘ └─────────┘登录认证
登录流程
- 获取验证码
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));
}- 用户登录
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安全配置建议
- 使用 HTTPS:生产环境必须使用 HTTPS
- Token 过期时间:Access Token 建议 24 小时,Refresh Token 建议 7 天
- 密码加密:使用 BCrypt 等强哈希算法
- 防止暴力破解:登录失败次数限制
- 敏感操作二次验证:如修改密码、删除数据等
- 定期更换密钥:JWT 密钥定期更换