/** * 自然写互动课堂教学管理云平台软件 V1.0 * * 用户认证控制器 * 负责用户登录、登出、Token刷新等认证相关接口 * 采用 JWT Token + Refresh Token 双令牌机制 */ package com.writech.cloud.controller; import com.writech.cloud.WritechCloudApplication.ApiResponse; import com.writech.cloud.WritechCloudApplication.BusinessException; import com.writech.cloud.model.User; import com.writech.cloud.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.Claims; import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import java.nio.charset.StandardCharsets; import java.util.*; import java.time.LocalDateTime; /** * 认证控制器 - /api/v1/auth * * 实现教师/学生/管理员/家长多角色用户的统一认证 * 支持手机号+密码、手机号+验证码、微信/钉钉第三方登录 */ @RestController @RequestMapping("/api/v1/auth") public class AuthController { @Autowired private UserService userService; /** JWT密钥 */ @Value("${writech.jwt.secret:writech-cloud-platform-jwt-secret-key-2026}") private String jwtSecret; /** Access Token 有效期(秒),默认2小时 */ @Value("${writech.jwt.access-token-expire:7200}") private long accessTokenExpire; /** Refresh Token 有效期(秒),默认7天 */ @Value("${writech.jwt.refresh-token-expire:604800}") private long refreshTokenExpire; /** * 用户登录接口 * POST /api/v1/auth/login * * 验证用户身份,签发 JWT Access Token 和 Refresh Token * Access Token 有效期2小时,Refresh Token 有效期7天 * * @param request 登录请求(包含手机号、密码/验证码、登录方式) * @return 包含双令牌和用户基本信息的响应 */ @PostMapping("/login") public ApiResponse login(@Valid @RequestBody LoginRequest request) { // 校验登录参数 if (request.getLoginType() == null) { throw new BusinessException(400, "登录方式不能为空"); } User user = null; // 根据不同登录方式验证身份 switch (request.getLoginType()) { case "password": // 手机号 + 密码登录 user = userService.verifyByPassword(request.getPhone(), request.getPassword()); break; case "sms": // 手机号 + 短信验证码登录 user = userService.verifyBySmsCode(request.getPhone(), request.getSmsCode()); break; case "wechat": // 微信授权登录 user = userService.verifyByWechat(request.getWechatCode()); break; case "dingtalk": // 钉钉授权登录 user = userService.verifyByDingtalk(request.getDingtalkCode()); break; default: throw new BusinessException(400, "不支持的登录方式: " + request.getLoginType()); } if (user == null) { throw new BusinessException(401, "登录失败,用户名或密码错误"); } // 检查用户状态 if (user.getStatus() != 1) { throw new BusinessException(403, "账户已被禁用,请联系管理员"); } // 生成双令牌 String accessToken = generateAccessToken(user); String refreshToken = generateRefreshToken(user); // 更新用户最后登录时间和登录IP userService.updateLoginInfo(user.getId(), LocalDateTime.now(), request.getClientIp()); // 构建登录响应 LoginResponse response = new LoginResponse(); response.setAccessToken(accessToken); response.setRefreshToken(refreshToken); response.setExpiresIn(accessTokenExpire); response.setUserId(user.getId()); response.setUserName(user.getName()); response.setRole(user.getRole()); response.setSchoolId(user.getSchoolId()); response.setSchoolName(user.getSchoolName()); return ApiResponse.success(response); } /** * Token 刷新接口 * POST /api/v1/auth/refresh * * 使用 Refresh Token 换取新的 Access Token * 避免用户频繁重新登录,提升使用体验 * * @param request 刷新请求(包含 Refresh Token) * @return 新的 Access Token */ @PostMapping("/refresh") public ApiResponse refreshToken(@Valid @RequestBody TokenRefreshRequest request) { try { // 解析并验证 Refresh Token Claims claims = parseToken(request.getRefreshToken()); String userId = claims.getSubject(); String tokenType = claims.get("type", String.class); // 确保是 Refresh Token 类型 if (!"refresh".equals(tokenType)) { throw new BusinessException(401, "无效的刷新令牌"); } // 查询用户信息(确保用户仍然有效) User user = userService.findById(userId); if (user == null || user.getStatus() != 1) { throw new BusinessException(401, "用户不存在或已被禁用"); } // 生成新的 Access Token String newAccessToken = generateAccessToken(user); TokenRefreshResponse response = new TokenRefreshResponse(); response.setAccessToken(newAccessToken); response.setExpiresIn(accessTokenExpire); return ApiResponse.success(response); } catch (Exception e) { throw new BusinessException(401, "令牌刷新失败: " + e.getMessage()); } } /** * 用户登出接口 * POST /api/v1/auth/logout * * 将当前 Token 加入黑名单,使其立即失效 * 同时清除 Redis 中的会话缓存 */ @PostMapping("/logout") public ApiResponse logout(@RequestHeader("Authorization") String authorization) { String token = extractToken(authorization); if (token != null) { // 将Token加入Redis黑名单,使其立即失效 userService.invalidateToken(token); } return ApiResponse.success(); } /** * 发送短信验证码 * POST /api/v1/auth/sms-code * * 向指定手机号发送登录验证码,验证码5分钟内有效 * 同一手机号60秒内只能发送一次 */ @PostMapping("/sms-code") public ApiResponse sendSmsCode(@RequestBody SmsCodeRequest request) { if (request.getPhone() == null || request.getPhone().length() != 11) { throw new BusinessException(400, "请输入正确的手机号"); } userService.sendSmsVerificationCode(request.getPhone()); return ApiResponse.success(); } /** * 获取当前登录用户信息 * GET /api/v1/auth/profile * * 根据 Token 中的用户ID查询完整的用户信息 * 包括角色、学校、班级等关联信息 */ @GetMapping("/profile") public ApiResponse getProfile(@RequestHeader("Authorization") String authorization) { String token = extractToken(authorization); Claims claims = parseToken(token); String userId = claims.getSubject(); User user = userService.findById(userId); if (user == null) { throw new BusinessException(404, "用户不存在"); } UserProfileResponse profile = new UserProfileResponse(); profile.setUserId(user.getId()); profile.setName(user.getName()); profile.setPhone(maskPhone(user.getPhone())); profile.setRole(user.getRole()); profile.setSchoolId(user.getSchoolId()); profile.setSchoolName(user.getSchoolName()); profile.setAvatar(user.getAvatar()); profile.setLastLoginTime(user.getLastLoginTime()); return ApiResponse.success(profile); } /** * 修改密码 * PUT /api/v1/auth/password */ @PutMapping("/password") public ApiResponse changePassword(@RequestHeader("Authorization") String authorization, @Valid @RequestBody ChangePasswordRequest request) { String token = extractToken(authorization); Claims claims = parseToken(token); String userId = claims.getSubject(); // 验证旧密码 boolean verified = userService.verifyPassword(userId, request.getOldPassword()); if (!verified) { throw new BusinessException(400, "原密码错误"); } // 更新密码 userService.updatePassword(userId, request.getNewPassword()); // 使所有现有Token失效,强制重新登录 userService.invalidateAllTokens(userId); return ApiResponse.success(); } // ==================== 内部方法 ==================== /** * 生成 Access Token * 有效期2小时,包含用户ID、角色、学校信息 */ private String generateAccessToken(User user) { SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); Date now = new Date(); Date expiry = new Date(now.getTime() + accessTokenExpire * 1000); return Jwts.builder() .setSubject(user.getId()) .claim("role", user.getRole()) .claim("schoolId", user.getSchoolId()) .claim("type", "access") .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) .compact(); } /** * 生成 Refresh Token * 有效期7天,仅包含用户ID和令牌类型 */ private String generateRefreshToken(User user) { SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); Date now = new Date(); Date expiry = new Date(now.getTime() + refreshTokenExpire * 1000); return Jwts.builder() .setSubject(user.getId()) .claim("type", "refresh") .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) .compact(); } /** 解析 JWT Token */ private Claims parseToken(String token) { SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } /** 从 Authorization 头中提取 Token */ private String extractToken(String authorization) { if (authorization != null && authorization.startsWith("Bearer ")) { return authorization.substring(7); } return null; } /** 手机号脱敏处理(中间4位替换为****) */ private String maskPhone(String phone) { if (phone == null || phone.length() != 11) return phone; return phone.substring(0, 3) + "****" + phone.substring(7); } // ==================== 请求/响应 DTO ==================== /** 登录请求 */ public static class LoginRequest { @NotBlank(message = "登录方式不能为空") private String loginType; // password/sms/wechat/dingtalk private String phone; private String password; private String smsCode; private String wechatCode; private String dingtalkCode; private String clientIp; public String getLoginType() { return loginType; } public void setLoginType(String loginType) { this.loginType = loginType; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getSmsCode() { return smsCode; } public void setSmsCode(String smsCode) { this.smsCode = smsCode; } public String getWechatCode() { return wechatCode; } public void setWechatCode(String wechatCode) { this.wechatCode = wechatCode; } public String getDingtalkCode() { return dingtalkCode; } public void setDingtalkCode(String dingtalkCode) { this.dingtalkCode = dingtalkCode; } public String getClientIp() { return clientIp; } public void setClientIp(String clientIp) { this.clientIp = clientIp; } } /** 登录响应 */ public static class LoginResponse { private String accessToken; private String refreshToken; private long expiresIn; private String userId; private String userName; private String role; private String schoolId; private String schoolName; public String getAccessToken() { return accessToken; } public void setAccessToken(String t) { this.accessToken = t; } public String getRefreshToken() { return refreshToken; } public void setRefreshToken(String t) { this.refreshToken = t; } public long getExpiresIn() { return expiresIn; } public void setExpiresIn(long e) { this.expiresIn = e; } public String getUserId() { return userId; } public void setUserId(String id) { this.userId = id; } public String getUserName() { return userName; } public void setUserName(String n) { this.userName = n; } public String getRole() { return role; } public void setRole(String r) { this.role = r; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getSchoolName() { return schoolName; } public void setSchoolName(String n) { this.schoolName = n; } } /** Token刷新请求 */ public static class TokenRefreshRequest { @NotBlank(message = "刷新令牌不能为空") private String refreshToken; public String getRefreshToken() { return refreshToken; } public void setRefreshToken(String t) { this.refreshToken = t; } } /** Token刷新响应 */ public static class TokenRefreshResponse { private String accessToken; private long expiresIn; public String getAccessToken() { return accessToken; } public void setAccessToken(String t) { this.accessToken = t; } public long getExpiresIn() { return expiresIn; } public void setExpiresIn(long e) { this.expiresIn = e; } } /** 短信验证码请求 */ public static class SmsCodeRequest { private String phone; public String getPhone() { return phone; } public void setPhone(String p) { this.phone = p; } } /** 用户信息响应 */ public static class UserProfileResponse { private String userId; private String name; private String phone; private String role; private String schoolId; private String schoolName; private String avatar; private LocalDateTime lastLoginTime; public String getUserId() { return userId; } public void setUserId(String id) { this.userId = id; } public String getName() { return name; } public void setName(String n) { this.name = n; } public String getPhone() { return phone; } public void setPhone(String p) { this.phone = p; } public String getRole() { return role; } public void setRole(String r) { this.role = r; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getSchoolName() { return schoolName; } public void setSchoolName(String n) { this.schoolName = n; } public String getAvatar() { return avatar; } public void setAvatar(String a) { this.avatar = a; } public LocalDateTime getLastLoginTime() { return lastLoginTime; } public void setLastLoginTime(LocalDateTime t) { this.lastLoginTime = t; } } /** 修改密码请求 */ public static class ChangePasswordRequest { @NotBlank(message = "原密码不能为空") private String oldPassword; @NotBlank(message = "新密码不能为空") private String newPassword; public String getOldPassword() { return oldPassword; } public void setOldPassword(String p) { this.oldPassword = p; } public String getNewPassword() { return newPassword; } public void setNewPassword(String p) { this.newPassword = p; } } }