refactor: 认证和短信发送重构

This commit is contained in:
Ray.Hao
2025-01-13 08:26:50 +08:00
parent 4fb317c4b3
commit d9e25874ed
20 changed files with 329 additions and 91 deletions

View File

@@ -5,7 +5,7 @@ import com.youlai.boot.common.result.Result;
import com.youlai.boot.shared.auth.model.RefreshTokenRequest;
import com.youlai.boot.shared.auth.service.AuthService;
import com.youlai.boot.shared.auth.model.CaptchaResponse;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.core.security.model.AuthToken;
import com.youlai.boot.common.annotation.Log;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -32,12 +32,12 @@ public class AuthController {
@Operation(summary = "登录")
@PostMapping("/login")
@Log(value = "登录", module = LogModuleEnum.LOGIN)
public Result<AuthTokenResponse> login(
public Result<AuthToken> login(
@Parameter(description = "用户名", example = "admin") @RequestParam String username,
@Parameter(description = "密码", example = "123456") @RequestParam String password
) {
AuthTokenResponse authTokenResponse = authService.login(username, password);
return Result.success(authTokenResponse);
AuthToken authToken = authService.login(username, password);
return Result.success(authToken);
}
@Operation(summary = "注销")
@@ -58,17 +58,38 @@ public class AuthController {
@Operation(summary = "刷新token")
@PostMapping("/refresh-token")
public Result<?> refreshToken(@RequestBody RefreshTokenRequest request) {
AuthTokenResponse authTokenResponse = authService.refreshToken(request);
return Result.success(authTokenResponse);
AuthToken authToken = authService.refreshToken(request);
return Result.success(authToken);
}
@Operation(summary = "微信登录")
@PostMapping("/wechat-login")
@Log(value = "微信登录", module = LogModuleEnum.LOGIN)
public Result<AuthTokenResponse> wechatLogin(
public Result<AuthToken> wechatLogin(
@Parameter(description = "微信授权码", example = "code") @RequestParam String code
) {
AuthTokenResponse loginResult = authService.wechatLogin(code);
AuthToken loginResult = authService.wechatLogin(code);
return Result.success(loginResult);
}
@Operation(summary = "短信验证码登录")
@PostMapping("/sms-login")
@Log(value = "短信验证码登录", module = LogModuleEnum.LOGIN)
public Result<AuthToken> smsLogin(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile,
@Parameter(description = "验证码", example = "123456") @RequestParam String code
) {
AuthToken loginResult = authService.smsLogin(mobile, code);
return Result.success(loginResult);
}
@Operation(summary = "短信验证码登录发送短信")
@PostMapping("/sms-login/verify-code")
public Result<?> sendLoginVerifyCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
authService.sendLoginVerifyCode(mobile);
return Result.success();
}
}

View File

@@ -1,31 +0,0 @@
package com.youlai.boot.shared.auth.model;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
* 认证令牌响应对象
*
* @author Ray
* @since 0.0.1
*/
@Schema(description = "认证令牌响应对象")
@Data
@Builder
public class AuthTokenResponse {
@Schema(description = "令牌类型", example = "Bearer")
private String tokenType;
@Schema(description = "访问令牌")
private String accessToken;
@Schema(description = "刷新令牌")
private String refreshToken;
@Schema(description = "过期时间(单位:秒)")
private Integer expiresIn;
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.shared.auth.service;
import com.youlai.boot.shared.auth.model.CaptchaResponse;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.core.security.model.AuthToken;
import com.youlai.boot.shared.auth.model.RefreshTokenRequest;
/**
@@ -19,7 +19,7 @@ public interface AuthService {
* @param password 密码
* @return 登录结果
*/
AuthTokenResponse login(String username, String password);
AuthToken login(String username, String password);
/**
* 登出
@@ -39,7 +39,7 @@ public interface AuthService {
* @param request 刷新令牌请求参数
* @return 登录结果
*/
AuthTokenResponse refreshToken(RefreshTokenRequest request);
AuthToken refreshToken(RefreshTokenRequest request);
/**
* 微信小程序登录
@@ -47,5 +47,12 @@ public interface AuthService {
* @param code 微信登录code
* @return 登录结果
*/
AuthTokenResponse wechatLogin(String code);
AuthToken wechatLogin(String code);
/**
* 发送短信验证码
*
* @param mobile 手机号
*/
void sendLoginVerifyCode(String mobile);
}

View File

@@ -1,61 +0,0 @@
package com.youlai.boot.shared.auth.service;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import org.springframework.security.core.Authentication;
/**
* 令牌接口
*
* @author Ray
* @since 2.16.0
*/
public interface TokenService {
/**
* 生成认证 Token
*
* @param authentication 用户认证信息
* @return 认证 Token 响应
*/
AuthTokenResponse generateToken(Authentication authentication);
/**
* 解析 Token 获取认证信息
*
* @param token JWT Token
* @return 用户认证信息
*/
Authentication parseToken(String token);
/**
* 校验 Token 是否有效
*
* @param token JWT Token
* @return 是否有效
*/
boolean validateToken(String token);
/**
* 刷新 Token
*
* @param token 刷新令牌
* @return 认证 Token 响应
*/
AuthTokenResponse refreshToken(String token);
/**
* 将 Token 加入黑名单
*
* @param token JWT Token
*/
default void blacklistToken(String token) {
// 默认实现可以是空的,或者抛出不支持的操作异常
// throw new UnsupportedOperationException("Not implemented");
}
}

View File

@@ -12,11 +12,13 @@ import com.youlai.boot.config.property.CaptchaProperties;
import com.youlai.boot.core.security.extension.WechatAuthenticationToken;
import com.youlai.boot.core.security.util.SecurityUtils;
import com.youlai.boot.shared.auth.enums.CaptchaTypeEnum;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.core.security.model.AuthToken;
import com.youlai.boot.shared.auth.model.CaptchaResponse;
import com.youlai.boot.shared.auth.model.RefreshTokenRequest;
import com.youlai.boot.shared.auth.service.AuthService;
import com.youlai.boot.shared.auth.service.TokenService;
import com.youlai.boot.core.security.manager.TokenManager;
import com.youlai.boot.shared.sms.enums.SmsTypeEnum;
import com.youlai.boot.shared.sms.service.SmsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
@@ -45,7 +47,9 @@ public class AuthServiceImpl implements AuthService {
private final CodeGenerator codeGenerator;
private final Font captchaFont;
private final CaptchaProperties captchaProperties;
private final TokenService tokenService;
private final TokenManager tokenManager;
private final SmsService smsService;
/**
* 用户名密码登录
@@ -55,7 +59,7 @@ public class AuthServiceImpl implements AuthService {
* @return 访问令牌
*/
@Override
public AuthTokenResponse login(String username, String password) {
public AuthToken login(String username, String password) {
// 1. 创建用于密码认证的令牌(未认证)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username.trim(), password);
@@ -64,7 +68,8 @@ public class AuthServiceImpl implements AuthService {
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthTokenResponse authTokenResponse = tokenService.generateToken(authentication);
AuthToken authTokenResponse =
tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authTokenResponse;
}
@@ -76,7 +81,7 @@ public class AuthServiceImpl implements AuthService {
* @return 访问令牌
*/
@Override
public AuthTokenResponse wechatLogin(String code) {
public AuthToken wechatLogin(String code) {
// 1. 创建用户微信认证的令牌(未认证)
WechatAuthenticationToken authenticationToken = new WechatAuthenticationToken(code);
@@ -84,10 +89,30 @@ public class AuthServiceImpl implements AuthService {
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthTokenResponse authTokenResponse = tokenService.generateToken(authentication);
AuthToken authTokenResponse = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authTokenResponse;
}
/**
* 发送短信验证码
*
* @param mobile 手机号
*/
@Override
public void sendLoginVerifyCode(String mobile) {
// 随机生成4位验证码
String verifyCode = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
// 发送短信验证码
smsService.sendSms(mobile, SmsTypeEnum.LOGIN, verifyCode);
}
/**
@@ -99,7 +124,7 @@ public class AuthServiceImpl implements AuthService {
if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
// 将JWT令牌加入黑名单
tokenService.blacklistToken(token);
tokenManager.blacklistToken(token);
// 清除Security上下文
SecurityContextHolder.clearContext();
}
@@ -156,18 +181,18 @@ public class AuthServiceImpl implements AuthService {
* @return 新的访问令牌
*/
@Override
public AuthTokenResponse refreshToken(RefreshTokenRequest request) {
public AuthToken refreshToken(RefreshTokenRequest request) {
// 验证刷新令牌
String refreshToken = request.getRefreshToken();
boolean isValidate = tokenService.validateToken(refreshToken);
boolean isValidate = tokenManager.validateToken(refreshToken);
if (!isValidate) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
}
return tokenService.refreshToken(refreshToken);
return tokenManager.refreshToken(refreshToken);
}

View File

@@ -1,222 +0,0 @@
package com.youlai.boot.shared.auth.service.impl;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTPayload;
import cn.hutool.jwt.JWTUtil;
import com.youlai.boot.common.constant.JwtClaimConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.core.security.model.SysUserDetails;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.shared.auth.service.TokenService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* JWT 令牌服务实现
*
* @author Ray.Hao
* @since 2024/11/15
*/
@ConditionalOnProperty(value = "security.session.type", havingValue = "jwt")
@Service
public class JwtTokenService implements TokenService {
private final SecurityProperties securityProperties;
private final RedisTemplate<String, Object> redisTemplate;
private final byte[] secretKey;
public JwtTokenService(SecurityProperties securityProperties, RedisTemplate<String, Object> redisTemplate) {
this.securityProperties = securityProperties;
this.redisTemplate = redisTemplate;
this.secretKey = securityProperties.getJwt().getKey().getBytes();
}
/**
* 生成令牌
*
* @param authentication 认证信息
* @return 令牌响应对象
*/
@Override
public AuthTokenResponse generateToken(Authentication authentication) {
int accessTokenTimeToLive = securityProperties.getJwt().getAccessTokenTimeToLive();
int refreshTokenTimeToLive = securityProperties.getJwt().getRefreshTokenTimeToLive();
String accessToken = generateToken(authentication, accessTokenTimeToLive);
String refreshToken = generateToken(authentication, refreshTokenTimeToLive);
return AuthTokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(accessTokenTimeToLive)
.build();
}
/**
* 解析令牌
*
* @param token JWT Token
* @return Authentication 对象
*/
@Override
public Authentication parseToken(String token) {
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合
Set<SimpleGrantedAuthority> authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES)
.stream()
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
.collect(Collectors.toSet());
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
/**
* 校验令牌
*
* @param token JWT Token
* @return 是否有效
*/
@Override
public boolean validateToken(String token) {
JWT jwt = JWTUtil.parseToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValid = jwt.setKey(secretKey).validate(0);
if (isValid) {
// 检查 Token 是否已被加入黑名单(注销、修改密码等场景)
JSONObject payloads = jwt.getPayloads();
String jti = payloads.getStr(JWTPayload.JWT_ID);
// 判断是否在黑名单中,如果在,则返回false 标识Token无效
if (Boolean.TRUE.equals(redisTemplate.hasKey(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti))) {
return false;
}
}
return isValid;
}
/**
* 将令牌加入黑名单
*
* @param token JWT Token
*/
@Override
public void blacklistToken(String token) {
if (token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
}
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
String jti = payloads.getStr(JWTPayload.JWT_ID);
Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT);
if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) {
// Token已过期,直接返回
return;
}
// 计算Token剩余时间,将其加入黑名单
int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti, null, expirationIn, TimeUnit.SECONDS);
} else {
// 永不过期的Token永久加入黑名单
redisTemplate.opsForValue().set(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti, null);
}
;
}
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 令牌响应对象
*/
@Override
public AuthTokenResponse refreshToken(String refreshToken) {
boolean isValid = validateToken(refreshToken);
if (!isValid) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
}
Authentication authentication = parseToken(refreshToken);
int accessTokenExpiration = securityProperties.getJwt().getRefreshTokenTimeToLive();
String newAccessToken = generateToken(authentication, accessTokenExpiration);
return AuthTokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(accessTokenExpiration)
.build();
}
/**
* 生成 JWT Token
*
* @param authentication 认证信息
* @param ttl 过期时间
* @return
*/
private String generateToken(Authentication authentication, int ttl) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围
// claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
payload.put(JwtClaimConstants.AUTHORITIES, roles);
Date now = new Date();
payload.put(JWTPayload.ISSUED_AT, now);
// 设置过期时间 -1 表示永不过期
if (ttl != -1) {
Date expiresAt = DateUtil.offsetSecond(now, ttl);
payload.put(JWTPayload.EXPIRES_AT, expiresAt);
}
payload.put(JWTPayload.SUBJECT, authentication.getName());
payload.put(JWTPayload.JWT_ID, IdUtil.simpleUUID());
return JWTUtil.createToken(payload, secretKey);
}
}

View File

@@ -1,62 +0,0 @@
package com.youlai.boot.shared.auth.service.impl;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.shared.auth.service.TokenService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
/**
* JWT 令牌服务实现
*
* @author Ray.Hao
* @since 2024/11/15
*/
@ConditionalOnProperty(value = "security.session.type", havingValue = "redis-token")
@Service
public class RedisTokenService implements TokenService {
/**
* 生成令牌
*
* @param authentication 用户认证信息
* @return
*/
@Override
public AuthTokenResponse generateToken(Authentication authentication) {
return null;
}
/**
* 解析令牌
*
* @param token JWT Token
* @return
*/
@Override
public Authentication parseToken(String token) {
return null;
}
/**
* 验证令牌
*
* @param token JWT Token
* @return
*/
@Override
public boolean validateToken(String token) {
return false;
}
/**
* 刷新令牌
*
* @param token 刷新令牌
* @return
*/
@Override
public AuthTokenResponse refreshToken(String token) {
return null;
}
}

View File

@@ -128,8 +128,8 @@ public class MinioFileService implements FileService {
/**
* 删除文件
*
* @param filePath 文件路径
* https://oss.youlai.tech/default/20221120/test.jpg
* @param filePath 文件路径 http://localhost:9000/default/20221120/test.jpg
*
* @return
*/
@Override
@@ -151,10 +151,8 @@ public class MinioFileService implements FileService {
minioClient.removeObject(removeObjectArgs);
return true;
} catch (ErrorResponseException | InsufficientDataException | InternalException | InvalidKeyException |
InvalidResponseException | IOException | NoSuchAlgorithmException | ServerException |
XmlParserException e) {
throw new RuntimeException(e);
} catch (Exception e) {
throw new RuntimeException("文件删除失败", e);
}
}

View File

@@ -6,6 +6,7 @@ package com.youlai.boot.shared.sms.controller;
* @author Ray
* @since 2.10.0
*/
public class SmsController {

View File

@@ -0,0 +1,22 @@
package com.youlai.boot.shared.sms.enums;
import com.youlai.boot.common.base.IBaseEnum;
import lombok.Getter;
/**
* 短信类型枚举
*/
@Getter
public enum SmsTypeEnum implements IBaseEnum<String> {
REGISTER("register", "注册短信验证码"),
LOGIN("login", "登录短信验证码"),
RESET_PASSWORD("reset-password", "重置密码短信验证码");
private final String value;
private final String label;
SmsTypeEnum(String value, String label) {
this.value = value;
this.label = label;
}
}

View File

@@ -1,11 +1,11 @@
package com.youlai.boot.shared.sms.service;
import com.youlai.boot.shared.sms.enums.SmsTypeEnum;
/**
* 短信服务接口层
* <p>
* SMS = Short Message Service 短信服务
*
* @author Ray
* @author Ray.Hao
* @since 2024/8/17
*/
public interface SmsService {
@@ -14,9 +14,9 @@ public interface SmsService {
* 发送短信
*
* @param mobile 手机号 13388886666
* @param templateCode 短信模板 SMS_194640010
* @param smsType 短信模板 SMS_194640010
* @param templateParam 模板参数 "[{"code":"123456"}]"
* @return boolean 是否发送成功
*/
boolean sendSms(String mobile, String templateCode, String templateParam);
boolean sendSms(String mobile, SmsTypeEnum smsType, String templateParam);
}

View File

@@ -9,6 +9,7 @@ import com.aliyuncs.exceptions.ServerException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.youlai.boot.config.property.AliyunSmsProperties;
import com.youlai.boot.shared.sms.enums.SmsTypeEnum;
import com.youlai.boot.shared.sms.service.SmsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -29,13 +30,15 @@ public class AliyunSmsService implements SmsService {
* 发送短信验证码
*
* @param mobile 手机号 13388886666
* @param templateCode 短信模板 SMS_194640010
* @param templateParam 模板参数 "[{"code":"123456"}]"
* @param smsType 短信模板 SMS_194640010
* @param templateParam 模板参数 [{"code":"123456"}]
*
* @return boolean 是否发送成功
*/
@Override
public boolean sendSms(String mobile,String templateCode,String templateParam) {
public boolean sendSms(String mobile, SmsTypeEnum smsType, String templateParam) {
String templateCode = aliyunSmsProperties.getTemplates().get(smsType.getValue());
DefaultProfile profile = DefaultProfile.getProfile(aliyunSmsProperties.getRegionId(),
aliyunSmsProperties.getAccessKeyId(), aliyunSmsProperties.getAccessKeySecret());
@@ -65,8 +68,6 @@ public class AliyunSmsService implements SmsService {
try {
CommonResponse response = client.getCommonResponse(request);
return response.getHttpResponse().isSuccess();
} catch (ServerException e) {
e.printStackTrace();
} catch (ClientException e) {
e.printStackTrace();
}