① 参数说明
{
"tenantName": "tenant1", //租户名称(必传),需事先在平台配置
"username": "admin", //用户名(必传),平台事先维护好用户
"password": "123", //密码(必传)
"captchaVerification": "PfcH6mgr==" //验证码(非必传),若开启的话需要前后台同时开启
}
其中租户名称在登录逻辑中用不到,用到的是租户对应的ID,接口请求的时候需要将Tenant-Id封装到请求头中,登录逻辑会从请求头中获取并使用,如下截图
特别注意:平台其它接口也都需要封装此租户ID
② 主要代码
1、控制层接口
`@PostMapping("/login")
@PermitAll
@Operation(summary = "使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
return success(authService.login(reqVO));
}`
2、参数对象 AuthLoginReqVO
@Schema(description = "管理后台 - 账号密码登录 Request VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthLoginReqVO {
@Schema(description = "账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "iailabyuanma")
@NotEmpty(message = "登录账号不能为空")
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
private String username;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;
// ========== 图片验证码相关 ==========
@Schema(description = "验证码,验证码开启时,需要传递", requiredMode = Schema.RequiredMode.REQUIRED,
example = "PfcH6mgr8tpXuMWFjvW6YVaqrswIuwmWI5dsVZSg7sGpWtDCUbHuDEXl3cFB1+VvCC/rAkSwK8Fad52FSuncVg==")
@NotEmpty(message = "验证码不能为空", groups = CodeEnableGroup.class)
private String captchaVerification;
}
3、登录实现类
@Override
public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
// 校验验证码
validateCaptcha(reqVO);
// 使用账号密码,进行登录
AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
// 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
}
4、验证用户名密码
@Override
public AdminUserDO authenticate(String username, String password) {
final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;
// 校验账号是否存在
AdminUserDO user = userService.getUserByUsername(username);
if (user == null) {
createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
}
if (!userService.isPasswordMatch(password, user.getPassword())) {
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
}
// 校验是否禁用
if (CommonStatusEnum.isDisable(user.getStatus())) {
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
throw exception(AUTH_LOGIN_USER_DISABLED);
}
return user;
}
用户名密码校验不通过常量如下图
5、创建令牌
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
// 插入登陆日志
createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
// 创建访问令牌
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
// 构建返回结果
return AuthConvert.INSTANCE.convert(accessTokenDO);
}
其中“OAuth2ClientConstants.CLIENT_ID_DEFAULT”是在平台配置好的默认客户端,主要包括授权类型、刷新token有效期、访问token有效期等
6、创建访问令牌和刷新令牌
@Override
@Transactional
public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes) {
OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
// 创建刷新令牌
OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes);
// 创建访问令牌
return createOAuth2AccessToken(refreshTokenDO, clientDO);
}
private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshTokenDO, OAuth2ClientDO clientDO) {
OAuth2AccessTokenDO accessTokenDO = new OAuth2AccessTokenDO().setAccessToken(generateAccessToken())
.setUserId(refreshTokenDO.getUserId()).setUserType(refreshTokenDO.getUserType())
.setUserInfo(buildUserInfo(refreshTokenDO.getUserId(), refreshTokenDO.getUserType()))
.setClientId(clientDO.getClientId()).setScopes(refreshTokenDO.getScopes())
.setRefreshToken(refreshTokenDO.getRefreshToken())
.setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getAccessTokenValiditySeconds()));
accessTokenDO.setTenantId(TenantContextHolder.getTenantId()); // 手动设置租户编号,避免缓存到 Redis 的时候,无对应的租户编号
oauth2AccessTokenMapper.insert(accessTokenDO);
// 记录到 Redis 中
oauth2AccessTokenRedisDAO.set(accessTokenDO);
return accessTokenDO;
}
private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType, OAuth2ClientDO clientDO, List<String> scopes) {
OAuth2RefreshTokenDO refreshToken = new OAuth2RefreshTokenDO().setRefreshToken(generateRefreshToken())
.setUserId(userId).setUserType(userType)
.setClientId(clientDO.getClientId()).setScopes(scopes)
.setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getRefreshTokenValiditySeconds()));
oauth2RefreshTokenMapper.insert(refreshToken);
return refreshToken;
}
注意:访问令牌是请求后端接口的通行证,所有需要鉴权的后端api都需要携带访问令牌,访问令牌过期后会请求刷新令牌接口,重新获取访问令牌。 当刷新令牌过期后,就需要重新登录获取授权了!
7、刷新令牌
@PostMapping("/refresh-token")
@PermitAll
@Operation(summary = "刷新令牌")
@Parameter(name = "refreshToken", description = "刷新令牌", required = true)
public CommonResult<AuthLoginRespVO> refreshToken(@RequestParam("refreshToken") String refreshToken) {
return success(authService.refreshToken(refreshToken));
}
@Override
public AuthLoginRespVO refreshToken(String refreshToken) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
return AuthConvert.INSTANCE.convert(accessTokenDO);
}
@Override
public OAuth2AccessTokenDO refreshAccessToken(String refreshToken, String clientId) {
// 查询访问令牌
OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
if (refreshTokenDO == null) {
throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "无效的刷新令牌");
}
// 校验 Client 匹配
OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
if (ObjectUtil.notEqual(clientId, refreshTokenDO.getClientId())) {
throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "刷新令牌的客户端编号不正确");
}
// 移除相关的访问令牌
List<OAuth2AccessTokenDO> accessTokenDOs = oauth2AccessTokenMapper.selectListByRefreshToken(refreshToken);
if (CollUtil.isNotEmpty(accessTokenDOs)) {
oauth2AccessTokenMapper.deleteBatchIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId));
oauth2AccessTokenRedisDAO.deleteList(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getAccessToken));
}
// 已过期的情况下,删除刷新令牌
if (DateUtils.isExpired(refreshTokenDO.getExpiresTime())) {
oauth2RefreshTokenMapper.deleteById(refreshTokenDO.getId());
throw exception0(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "刷新令牌已过期");
}
// 创建访问令牌
return createOAuth2AccessToken(refreshTokenDO, clientDO);
}
8、前端封装token和租户ID代码参考
// request拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 是否需要设置 token
let isToken = (config!.headers || {}).isToken === false
whiteList.some((v) => {
if (config.url && config.url.indexOf(v) > -1) {
return (isToken = false)
}
})
if (getAccessToken() && !isToken) {
config.headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
}
// 设置租户
if (tenantEnable && tenantEnable === 'true') {
const tenantId = getTenantId()
if (tenantId) config.headers['tenant-id'] = tenantId
}
const method = config.method?.toUpperCase()
// 防止 GET 请求缓存
if (method === 'GET') {
config.headers['Cache-Control'] = 'no-cache'
config.headers['Pragma'] = 'no-cache'
}
// 自定义参数序列化函数
else if (method === 'POST') {
const contentType = config.headers['Content-Type'] || config.headers['content-type']
if (contentType === 'application/x-www-form-urlencoded') {
if (config.data && typeof config.data !== 'string') {
config.data = qs.stringify(config.data)
}
}
}
return config
},
(error: AxiosError) => {
// Do something with request error
console.log(error) // for debug
return Promise.reject(error)
}
)
核心代码如下
@PostMapping("/logout")
@PermitAll
@Operation(summary = "登出系统")
public CommonResult<Boolean> logout(HttpServletRequest request) {
String token = SecurityFrameworkUtils.obtainAuthorization(request,
securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
if (StrUtil.isNotBlank(token)) {
authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType());
}
return success(true);
}
@Override
public void logout(String token, Integer logType) {
// 删除访问令牌
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token);
if (accessTokenDO == null) {
return;
}
// 删除成功,则记录登出日志
createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType);
}
@Override
public OAuth2AccessTokenDO removeAccessToken(String accessToken) {
// 删除访问令牌
OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenMapper.selectByAccessToken(accessToken);
if (accessTokenDO == null) {
return null;
}
oauth2AccessTokenMapper.deleteById(accessTokenDO.getId());
oauth2AccessTokenRedisDAO.delete(accessToken);
// 删除刷新令牌
oauth2RefreshTokenMapper.deleteByRefreshToken(accessTokenDO.getRefreshToken());
return accessTokenDO;
}