完成 oauth2 password 密码模式的实现

完成 oauth2 revokeToken 的实现
pull/2/head
YunaiV 2022-05-15 17:36:22 +08:00
parent 99ba7ccee8
commit 0900b9e111
11 changed files with 228 additions and 44 deletions

View File

@ -21,3 +21,24 @@ Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenentId}}
grant_type=authorization_code&redirect_uri=https://www.iocoder.cn
### 请求 /system/oauth2/token + password 接口 => 成功
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenentId}}
grant_type=password&username=admin&password=admin123&scope=user_info
### 请求 /system/oauth2/token + refresh_token 接口 => 成功
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenentId}}
grant_type=refresh_token&refresh_token=00895465d6994f72a9d926ceeed0f588
### 请求 /system/oauth2/token + DELETE 接口 => 成功
DELETE {{baseUrl}}/system/oauth2/token?token=ca8a188f464441d6949c51493a2b7596
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenentId}}

View File

@ -47,8 +47,6 @@ public class OAuth2OpenController {
// POST oauth/check_token CheckTokenEndpoint
// DELETE oauth/token ConsumerTokenServices#revokeToken
@Resource
private OAuth2GrantService oauth2GrantService;
@Resource
@ -56,20 +54,38 @@ public class OAuth2OpenController {
@Resource
private OAuth2ApproveService oauth2ApproveService;
/**
* Spring Security OAuth TokenEndpoint postAccessToken
*
* authorization_code code + redirectUri + state
* password username + password + scope
* refresh_token refreshToken
* client_credentials scope
* implicit
*
* client_id + client_secret
*/
@PostMapping("/token")
@ApiOperation(value = "获得访问令牌", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.vue 单点登录界面被【获取】调用")
@ApiImplicitParams({
@ApiImplicitParam(name = "grant_type", required = true, value = "授权类型", example = "code", dataTypeClass = String.class),
@ApiImplicitParam(name = "code", value = "授权范围", example = "userinfo.read", dataTypeClass = String.class),
@ApiImplicitParam(name = "redirect_uri", value = "重定向 URI", example = "https://www.iocoder.cn", dataTypeClass = String.class),
@ApiImplicitParam(name = "state", example = "123321", dataTypeClass = String.class)
@ApiImplicitParam(name = "username", example = "tudou", dataTypeClass = String.class),
@ApiImplicitParam(name = "password", example = "cai", dataTypeClass = String.class), // 多个使用空格分隔
@ApiImplicitParam(name = "scope", example = "user_info", dataTypeClass = String.class)
})
@OperateLog(enable = false) // 避免 Post 请求被记录操作日志
public CommonResult<OAuth2OpenAccessTokenRespVO> postAccessToken(HttpServletRequest request,
@RequestParam("grant_type") String grantType,
@RequestParam(value = "code", required = false) String code, // 授权码模式
@RequestParam(value = "redirect_uri", required = false) String redirectUri, // 授权码模式
@RequestParam(value = "state", required = false) String state) { // 授权码模式
@RequestParam(value = "state", required = false) String state, // 授权码模式
@RequestParam(value = "username", required = false) String username, // 密码模式
@RequestParam(value = "password", required = false) String password, // 密码模式
@RequestParam(value = "scope", required = false) String scope, // 密码模式
@RequestParam(value = "refresh_token", required = false) String refreshToken) { // 刷新模式
List<String> scopes = OAuth2Utils.buildScopes(scope);
// 授权类型
OAuth2GrantTypeEnum grantTypeEnum = OAuth2GrantTypeEnum.getByGranType(grantType);
if (grantTypeEnum == null) {
@ -80,23 +96,24 @@ public class OAuth2OpenController {
}
// 校验客户端
String[] clientIdAndSecret = HttpUtils.obtainBasicAuthorization(request);
if (ArrayUtil.isEmpty(clientIdAndSecret) || clientIdAndSecret.length != 2) {
throw exception0(BAD_REQUEST.getCode(), "client_id 或 client_secret 未正确传递");
}
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1], grantType, null, null);
String[] clientIdAndSecret = obtainBasicAuthorization(request);
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1],
grantType, scopes, redirectUri);
// 根据授权模式,获取访问令牌
OAuth2AccessTokenDO accessTokenDO = null;
OAuth2AccessTokenDO accessTokenDO;
switch (grantTypeEnum) {
case AUTHORIZATION_CODE:
accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken(client.getClientId(), code, redirectUri, state);
break;
case PASSWORD:
accessTokenDO = oauth2GrantService.grantPassword(username, password, client.getClientId(), scopes);
break;
case CLIENT_CREDENTIALS:
accessTokenDO = oauth2GrantService.grantClientCredentials(client.getClientId(), scopes);
break;
case REFRESH_TOKEN:
accessTokenDO = oauth2GrantService.grantRefreshToken(refreshToken, client.getClientId());
break;
default:
throw new IllegalArgumentException("未知授权类型:" + grantType);
@ -105,6 +122,24 @@ public class OAuth2OpenController {
return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO));
}
@DeleteMapping("/token")
@ApiOperation(value = "删除访问令牌")
@ApiImplicitParam(name = "token", required = true, value = "访问令牌", example = "biu", dataTypeClass = String.class)
@OperateLog(enable = false) // 避免 Post 请求被记录操作日志
public CommonResult<Boolean> revokeToken(HttpServletRequest request,
@RequestParam("token") String token) {
// 校验客户端
String[] clientIdAndSecret = obtainBasicAuthorization(request);
if (ArrayUtil.isEmpty(clientIdAndSecret) || clientIdAndSecret.length != 2) {
throw exception0(BAD_REQUEST.getCode(), "client_id 或 client_secret 未正确传递");
}
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientIdAndSecret[0], clientIdAndSecret[1],
null, null, null);
// 删除访问令牌
return success(oauth2GrantService.revokeToken(client.getClientId(), token));
}
// GET oauth/authorize AuthorizationEndpoint TODO
@GetMapping("/authorize")
@ApiOperation(value = "获得授权信息", notes = "适合 code 授权码模式,或者 implicit 简化模式;在 authorize.vue 单点登录界面被【获取】调用")
@ -229,4 +264,12 @@ public class OAuth2OpenController {
return UserTypeEnum.ADMIN.getValue();
}
private String[] obtainBasicAuthorization(HttpServletRequest request) {
String[] clientIdAndSecret = HttpUtils.obtainBasicAuthorization(request);
if (ArrayUtil.isEmpty(clientIdAndSecret) || clientIdAndSecret.length != 2) {
throw exception0(BAD_REQUEST.getCode(), "client_id 或 client_secret 未正确传递");
}
return clientIdAndSecret;
}
}

View File

@ -29,4 +29,7 @@ public class OAuth2OpenAccessTokenRespVO {
@JsonProperty("expires_in")
private Long expiresIn;
@ApiModelProperty(value = "授权范围", example = "user_info", notes = "如果多个授权范围,使用空格分隔")
private String scope;
}

View File

@ -16,6 +16,7 @@ public interface OAuth2OpenConvert {
OAuth2OpenAccessTokenRespVO respVO = convert0(bean);
respVO.setTokenType(SecurityFrameworkUtils.AUTHORIZATION_BEARER.toLowerCase());
respVO.setExpiresIn(OAuth2Utils.getExpiresIn(bean.getExpiresTime()));
respVO.setScope(OAuth2Utils.buildScopeStr(bean.getScopes()));
return respVO;
}

View File

@ -37,6 +37,7 @@ public class SecurityConfiguration {
registry.antMatchers(buildAdminApi("/system/sms/callback/**")).permitAll();
// OAuth2 API
registry.antMatchers(buildAdminApi("/system/oauth2/token")).permitAll();
registry.antMatchers(buildAdminApi("/system/oauth2/check_token")).permitAll();
}
};

View File

@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.system.service.auth;
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import javax.validation.Valid;
@ -13,6 +14,15 @@ import javax.validation.Valid;
*/
public interface AdminAuthService {
/**
* +
*
* @param username
* @param password
* @return
*/
AdminUserDO authenticate(String username, String password);
/**
*
*

View File

@ -62,13 +62,34 @@ public class AdminAuthServiceImpl implements AdminAuthService {
@Resource
private SmsCodeApi smsCodeApi;
@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 (ObjectUtil.notEqual(user.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
throw exception(AUTH_LOGIN_USER_DISABLED);
}
return user;
}
@Override
public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
// 判断验证码是否正确
verifyCaptcha(reqVO);
// 使用账号密码,进行登录
AdminUserDO user = login0(reqVO.getUsername(), reqVO.getPassword());
AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
// 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
@ -125,27 +146,6 @@ public class AdminAuthServiceImpl implements AdminAuthService {
captchaService.deleteCaptchaCode(reqVO.getUuid());
}
@VisibleForTesting
AdminUserDO login0(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 (ObjectUtil.notEqual(user.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
throw exception(AUTH_LOGIN_USER_DISABLED);
}
return user;
}
private void createLoginLog(Long userId, String username,
LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) {
// 插入登录日志
@ -187,7 +187,7 @@ public class AdminAuthServiceImpl implements AdminAuthService {
@Override
public AuthLoginRespVO socialBindLogin(AuthSocialBindLoginReqVO reqVO) {
// 使用账号密码,进行登录。
AdminUserDO user = login0(reqVO.getUsername(), reqVO.getPassword());
AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
// 绑定社交用户
socialUserService.bindSocialUser(AuthConvert.INSTANCE.convert(user.getId(), getUserType().getValue(), reqVO));

View File

@ -18,7 +18,17 @@ import java.util.List;
*/
public interface OAuth2GrantService {
// ImplicitTokenGranter
/**
*
*
* Spring Security OAuth2 ImplicitTokenGranter
*
* @param userId
* @param userType
* @param clientId
* @param scopes
* @return 访
*/
OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
String clientId, List<String> scopes);
@ -53,4 +63,51 @@ public interface OAuth2GrantService {
OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken(String clientId, String code,
String redirectUri, String state);
/**
*
*
* Spring Security OAuth2 ResourceOwnerPasswordTokenGranter
*
* @param username
* @param password
* @param clientId
* @param scopes
* @return 访
*/
OAuth2AccessTokenDO grantPassword(String username, String password,
String clientId, List<String> scopes);
/**
*
*
* Spring Security OAuth2 ResourceOwnerPasswordTokenGranter
*
* @param refreshToken
* @param clientId
* @return 访
*/
OAuth2AccessTokenDO grantRefreshToken(String refreshToken, String clientId);
/**
*
*
* Spring Security OAuth2 ClientCredentialsTokenGranter
*
* @param clientId
* @param scopes
* @return 访
*/
OAuth2AccessTokenDO grantClientCredentials(String clientId, List<String> scopes);
/**
* 访
*
* Spring Security OAuth2 ConsumerTokenServices revokeToken
*
* @param accessToken 访
* @param clientId
* @return
*/
boolean revokeToken(String clientId, String accessToken);
}

View File

@ -1,9 +1,14 @@
package cn.iocoder.yudao.module.system.service.oauth2;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.auth.OAuth2CodeDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.system.service.auth.AdminAuthService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@ -23,6 +28,8 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
@Resource
private OAuth2TokenService oauth2TokenService;
@Resource
private AdminAuthService adminAuthService;
@Override
public OAuth2AccessTokenDO grantImplicit(Long userId, Integer userType,
@ -65,4 +72,36 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService {
codeDO.getClientId(), codeDO.getScopes());
}
@Override
public OAuth2AccessTokenDO grantPassword(String username, String password, String clientId, List<String> scopes) {
// 使用账号 + 密码进行登录
AdminUserDO user = adminAuthService.authenticate(username, password);
Assert.notNull(user, "用户不能为空!"); // 防御性编程
// 创建访问令牌
return oauth2TokenService.createAccessToken(user.getId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes);
}
@Override
public OAuth2AccessTokenDO grantRefreshToken(String refreshToken, String clientId) {
return oauth2TokenService.refreshAccessToken(refreshToken, clientId);
}
@Override
public OAuth2AccessTokenDO grantClientCredentials(String clientId, List<String> scopes) {
// TODO 芋艿:项目中使用 OAuth2 解决的是三方应用的授权,内部的 SSO 等问题,所以暂时不考虑 client_credentials 这个场景
throw new UnsupportedOperationException("暂时不支持 client_credentials 授权模式");
}
@Override
public boolean revokeToken(String clientId, String accessToken) {
// 先查询,保证 clientId 时匹配的
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.getAccessToken(accessToken);
if (accessTokenDO == null || ObjectUtil.notEqual(clientId, accessTokenDO.getClientId())) {
return false;
}
// 再删除
return oauth2TokenService.removeAccessToken(accessToken) != null;
}
}

View File

@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.system.util.oauth2;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
@ -58,7 +59,7 @@ public class OAuth2Utils {
vars.put("expires_in", getExpiresIn(expireTime));
}
if (CollUtil.isNotEmpty(scopes)) {
vars.put("scope", CollUtil.join(scopes, " "));
vars.put("scope", buildScopeStr(scopes));
}
for (String key : additionalInformation.keySet()) {
Object value = additionalInformation.get(key);
@ -86,4 +87,12 @@ public class OAuth2Utils {
return (expireTime.getTime() - System.currentTimeMillis()) / 1000;
}
public static String buildScopeStr(Collection<String> scopes) {
return CollUtil.join(scopes, " ");
}
public static List<String> buildScopes(String scope) {
return StrUtil.split(scope, ' ');
}
}

View File

@ -34,7 +34,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@Import(AdminAuthServiceImpl.class)
public class AuthServiceImplTest extends BaseDbUnitTest {
public class AdminAuthServiceImplTest extends BaseDbUnitTest {
@Resource
private AdminAuthServiceImpl authService;
@ -63,7 +63,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
}
@Test
public void testLogin0_success() {
public void testAuthenticate_success() {
// 准备参数
String username = randomString();
String password = randomString();
@ -75,19 +75,19 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
when(userService.isPasswordMatch(eq(password), eq(user.getPassword()))).thenReturn(true);
// 调用
AdminUserDO loginUser = authService.login0(username, password);
AdminUserDO loginUser = authService.authenticate(username, password);
// 校验
assertPojoEquals(user, loginUser);
}
@Test
public void testLogin0_userNotFound() {
public void testAuthenticate_userNotFound() {
// 准备参数
String username = randomString();
String password = randomString();
// 调用, 并断言异常
AssertUtils.assertServiceException(() -> authService.login0(username, password),
AssertUtils.assertServiceException(() -> authService.authenticate(username, password),
AUTH_LOGIN_BAD_CREDENTIALS);
verify(loginLogService).createLoginLog(
argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType())
@ -97,7 +97,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
}
@Test
public void testLogin0_badCredentials() {
public void testAuthenticate_badCredentials() {
// 准备参数
String username = randomString();
String password = randomString();
@ -107,7 +107,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
when(userService.getUserByUsername(eq(username))).thenReturn(user);
// 调用, 并断言异常
AssertUtils.assertServiceException(() -> authService.login0(username, password),
AssertUtils.assertServiceException(() -> authService.authenticate(username, password),
AUTH_LOGIN_BAD_CREDENTIALS);
verify(loginLogService).createLoginLog(
argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType())
@ -117,7 +117,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
}
@Test
public void testLogin0_userDisabled() {
public void testAuthenticate_userDisabled() {
// 准备参数
String username = randomString();
String password = randomString();
@ -129,7 +129,7 @@ public class AuthServiceImplTest extends BaseDbUnitTest {
when(userService.isPasswordMatch(eq(password), eq(user.getPassword()))).thenReturn(true);
// 调用, 并断言异常
AssertUtils.assertServiceException(() -> authService.login0(username, password),
AssertUtils.assertServiceException(() -> authService.authenticate(username, password),
AUTH_LOGIN_USER_DISABLED);
verify(loginLogService).createLoginLog(
argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType())