443 lines
17 KiB
Java
443 lines
17 KiB
Java
/**
|
||
* 自然写互动课堂教学管理云平台软件 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<LoginResponse> 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<TokenRefreshResponse> 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<Void> 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<Void> 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<UserProfileResponse> 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<Void> 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; }
|
||
}
|
||
}
|