Featured image of post 单点登录 Oauth2.0 集成方案

单点登录 Oauth2.0 集成方案

Oauth2.0 简介

在详细介绍 Oauth2.0 之前,需要了解几个专用名词。它们对读懂后边的讲解至关重要。

  1. Third-party application: 第三方应用程序,又称为"客户端(client)"。
  2. HTTP Service: HTTP 服务提供商。
  3. Resource Owner: 资源所有者,本文称"用户"(user)。
  4. User Agent: 用户代理,本文指浏览器。
  5. Authorization server: 认证服务器,即服务提供商专门用来处理认证的服务器。
  6. Resource server: 资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

了解以上名词,可以理解 OAuth 的作用就是让"客户端"安全可控地获取"用户"的授权,与"服务提供商"进行互动。

OAuth 的思路以及运行流程

OAuth 在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。“客户端"不能直接登录"服务提供商”,只能登录授权层,以此将用户与客户端区分。

“客户端"登录授权层所用令牌(token),与用户密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期

image.png

交互流程

  • (A) 客户端向资源所有者(用户)发起授权请求。
  • (B) 资源所有者(用户)返回授权许可给客户端,同意授权。
  • (C) 客户端将授权许可发送给认证服务器,申请令牌。
  • (D) 认证服务器对客户端进行认证以后,返回访问令牌给客户端。
  • (E) 客户端使用访问令牌向资源服务器请求资源。
  • (F) 资源服务器确认令牌无误,同意向客户端开放资源。

从上边流程可以看出,B是关键,用户怎么样才能给客户端授权是接下来要研究的。有了授权,客户端就可以获取令牌,进而凭借令牌获取资源。

RFC 6749

OAuth2.0 的标准是 RFC 6749 文件提出来的,该文件首先解释了 OAuth 说是什么?

OAuth 引入授权层,用来分离不同角色:客户端和资源拥有者。资源所有者同意之后,资源服务器才可以向客户端颁发令牌。客户端通过令牌,去请求数据。(由于互联网有多种场景…)本标准定义了获得令牌的四种授权方式(authorization grant)。

OAuth2.0 是目前最流行的授权机制,用来授权第三方应用,获取用户数据。

OAuth2.0 规定了四种获得令牌的流程。对于资源所有者来说,可以选择最适合一种向第三方应用颁发令牌。下边是这四种授权方式:

  • 授权码(authoization-code)
  • 隐藏式(implicit)
  • 密码式(password)
  • 客户端凭证(client credentials)

注意:不管用哪种授权方式,第三方应用申请令牌之前,必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。 这是为了防止令牌滥用,没有备案过的第三方应用,是不会拿到访问令牌的。

授权码模式

隐藏模式

密码模式

客户端凭证模式

应用系统集成步骤

配置对接信息

应用系统集成 IAM 的 OAuth 认证前,需提供:

  1. 应用系统名称
  2. 应用系统简称编码
  3. 应用系统访问地址
  4. 应用系统登出地址

IAM 统一认证身份系统管理员将上述信息注册完成后,会提供 client_idclient_secret 给应用系统。

功能联调

当双方功能开发完毕,统一在开发环境进行功能联调,功能没问题之后各自将配置、代码迁移到测试环境,进行业务 UAT 测试。

线上正式接入

业务测试环境通过之后,根据测试结果、双方协商上线节点、上线内容、上线步骤、遗留事项等,通过邮件确认,按计划在规定时间内上线。

认证集成环境示例

开发环境地址:https://iamdev.kitebin.top (需要本地配置 hosts: iamdev.kitebin.top 10.9.42.208)

测试环境地址:https://ssosit.kitebin.top

正式环境:上线时提供替换配置。

认证服务 Oauth2 协议(接口)

认证及单点登录流程

image.png

Oauth2 接口说明

请求授权接口,获取 code

字段
接口名 authorize
URL Path https://认证中心域名/idp/authCenter/authenticate
请求类型 GET
请求示例 https://认证中心域名/idp/authCenter/authenticate?response_type=code&state=1&redirect_uri=http://XXX.com/demo&client_id=oauth2Demo

备注:
1. 此示例中参数以实际对接情况为主
2. 应用系统不可将参数拼接好的授权接口直接提供给用户收藏使用,必须使用应用系统实际地址访问
处理逻辑 1. 判断参数
2. 验证 client_id 是否有效
3. 校验 redirect_uri,BAM-CONSOLE中可以填多个URL(以”;“分隔),判断参数中的URL是否以BAM-CONSOLE中填写的URL开头
4. 显示认证授权页面
5. 验证身份后页面跳转至redirect_uri并附有参数授权码
返回值 类型:String。
以上文中的回调地址测试应用为例,授权完成后跳转至:http://XXX.com/demo?code=414442c7230b7d56f940090c6cc7ffcc&state=1。携带参数code和state。
参数不完整及错误时,idp错误页面进行显示。
client_id缺失时:Invalid_request, Missing parameters: client_id
redirect_uri缺失时:Invalid_request, Missing parameters: redirect_uri
grant_type缺失时:Invalid_request, Missing parameters: response_type
client_id无效时:Unauthorized_client, client_id not accept
redirect_uri无效时:Unauthorized_redirect_uri, Redirect_uri not accept
grant_type无效时:Unsupported_response_type, Response_type id not accept
描述 1. 如果用户已完成过登录,访问此地址会跳转到指定的回调地址,带上code。如果请求参数中传入了state,这里会带上原始的state值。
2. 如果用户未登录,访问此地址会跳转至登录页面,显示应用配置的认证方式,用户完成登录后跳转到指定的回调地址,带上code。如果请求参数中传入了state,这里会带上原始的state值。

请求参数

参数名 类型 必填 中文说明 描述
response_type string 响应类型 固定值为 code,表示授权码模式
client_id string 客户端ID,应用标识 客户端应用注册ID
redirect_uri string 跳转地址 授权成功后跳转地址(uri编码)
state string 任意值 用于保持请求和回调状态,在回调时,会在 QueryParameter中回传该参数。开发者可以用这个参数验证请求的有效性,也可以记录用户请求授权页前的位置。可用于防止CSRF攻击的随机字符串。
scope string 权限范围 请求的权限范围,如 read write

获取授权接口,通过 code 获取 access_token

字段
接口名 getToken
URL Path https://认证中心域名:8083/bam-protocol-service/oauth2/getToken
请求类型 POST
请求示例 https://认证中心域名:8083/bam-protocol-service/oauth2/getToken?client_id=xxxxxx&grant_type=authorization_code&code=xxxxxx&client_secret=xxxxxx
处理逻辑 1. 验证参数有效性
2. 验证授权码有效性及范围
3. 根据上述判断、验证及认证结果返回 JSON 数据
返回值 类型 JSON
正确返回时:

{
“access_token”: “skiew234i3i4o6uy77b4k3b3v2j1vv53j”,
“expires_in”: “1500”,
“refresh_token”: “iewoer233422i34o2i34uio55iojhg6g”,
“uid”: “20221123092851812-3272-F82D3EAC0”
}

参数不完整及错误时:
client_id缺失时:
{
“errcode”: “1001”,
“msg”: “缺少参数client_id”
}
code缺失时:
{
“errcode”: “1091”,
“msg”: “缺少参数code”
}
grant_type缺失时:
{
“errcode”: “1010”,
“msg”: “缺少参数grant_type”
}
client_secret缺失时:
{
“errcode”: “1006”,
“msg”: “缺少参数client_secret”
}
client_id无效时:
{
“errcode”: “1004”,
“msg”: “参数client_id非法”
}
code无效时:
{
“errcode”: “1023”,
“msg”: “参数code非法”
}
client_secret无效时:
{
“errcode”: “1022”,
“msg”: “参数client_secret非法”
}
描述 OAuth获取授权Token接口可以获得access_token、expires_in、refresh_token、uid。access_token用于获取用户信息,expires_in是access_token的有效时长,时长在console应用注册时配置。refresh_token可在access_token到期后进行刷新续期,uid为登录用户uid。

请求参数

参数名 类型 必填 中文说明 描述
grant_type string 认证方式 请求类型,默认固定值为 authorization_code
code string 授权码 调用authorize接口获得的授权码code
client_id string 应用标识 客户端应用注册ID
client_secret string 客户端密钥 客户端应用注册密钥
redirect_uri string 重定向URI 必须与授权请求中的redirect_uri一致

获取用户信息接口,通过 access_token 获取登录用户信息

字段
接口名 getUserInfo
URL Path https://认证中心域名:8083/bam-protocol-service/oauth2/getUserInfo
请求类型 GET
请求示例 https://认证中心域名:8083/bam-protocol-service/oauth2/getUserInfo?access_token=skiew234i3i4o6uy77b4k3b3v2j1vv53j&client_id=20220804394895043-E8C0-B3FDEC23
处理逻辑 1. 验证参数有效性
2. 根据应用配置的属性列表,查询用户信息返回
3. 根据上述判断、验证及认证结果返回 JSON 数据
4. 正常返回用户信息之后,应用系统必须要以 spRoleList 中账号信息为准
返回值 类型:String,即:以JSON格式返回角色数据集
参数不完整及错误时:
client_id缺失时:
{
“errcode”: “1001”,
“msg”: “缺少参数client_id”
}
access_token缺失时:
{
“errcode”: “2001”,
“msg”: “缺少参数access_token”
}
client_id无效时:
{
“errcode”: “1008”,
“msg”: “参数client_id非法”
}
access_token无效时:
{
“errcode”: “1015”,
“msg”: “参数access_token不正确或过期”
}

正确返回时:
{
“loginName”: “10001”,
“spRoleList”: [
“100001”
]
}
描述 获取用户信息接口。返回值 errcode 为错误代码,msg 为提示信息。正确返回时,loginName 一般为用户唯一标识(可以为工号等),spRoleList 用户角色列表。

请求参数

参数名 类型 必填 中文说明 描述
access_token string 授权码token值 认证接口中返回的授权码token
client_id string 应用标识 注册应用时分配的客户端标识

认证注销接口

字段
接口名 GLO
URL Path https://认证中心域名/idp/authCenter/GLO?redirectToLogin=true&redirectToUrl=http://172.15.4.95:8088/oauth-demo/home
请求类型 GET,前端调用,跳转url
处理逻辑 1. 判断参数
2. 根据以上判断、验证及认证结果返回JSON数据
返回值 类型:String,即:以JSON格式返回角色数据集
参数不完整及错误时:
url缺失时:
{
“errcode”: “4001”,
“msg”: “缺少参数url”
}
描述 后端超时自动注销时,请勿调用

请求参数

参数名 类型 必填 中文说明 描述
redirectToUrl string 回调url 应用登录地址
注:url必须为一个完整有效的地址
redirectToLogin string true

第三方应用授权码模式单点登录对接 IAM 示例

单点配置文件

.env环境变量

1
2
3
4
5
6
7
8
9
# oa单点登录配置
oa_login_grantType=authorization_code
oa_login_clientId=APPNAME_S4933
oa_login_clientSecret=a25a1e314677449883ea3c9dadacfc4
oa_login_host=https://ssosit.kitebin.top
oa_login_accessTokenUrl=:8083/bam-protocol-service/oauth2/getToken?client_id={clientId}&grant_type=authorization_code&code={code}&client_secret={clientSecret}
oa_login_oaUserLoginUrl=:8083/bam-protocol-service/oauth2/getUserInfo?access_token={accessToken}&client_id={clientId}
oa_login_oaAuthLoginUrl=/idp/authCenter/authenticate?response_type=code&state=1&client_id={clientId}&redirect_uri=
oa_login_redirectUrl=https://第三方应用域名/iam-redirect-proxy?url=

application.yml文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
oa:
  login:
    grantType: ${oa_login_grantType}
    clientId: ${oa_login_clientId}
    clientSecret: ${oa_login_clientSecret}
    host: ${oa_login_host}
    accessTokenUrl: ${oa_login_accessTokenUrl}
    oaUserLoginUrl: ${oa_login_oaUserLoginUrl}
    oaAuthLoginUrl: ${oa_login_oaAuthLoginUrl}
    redirectUrl: ${oa_login_redirectUrl}

读取配置文件属性类 OaLoginProperties.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
@Component
@ConfigurationProperties(prefix = "oa.login")
public class OaLoginProperties {
	
	private String grantType;
	
	private String clientId;
	
	private String clientSecret;
	
	private String host;
	
	private String accessTokenUrl;
	
	private String oaUserLoginUrl;
	
	private String oaAuthLoginUrl;
	
	private String redirectUrl;
}

单点登录核心接口代码

登录接口控制层 LoginController.java

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@RestController
public class LoginController {

	@Resource
	private LoginService loginService;
	
	@Resource
	private OaLoginProperties oaLoginProperties;
	
	@RequestMapping(value = "/login", method = RequestMethod.POST)
	public BaseResponse<UserPo> login(@RequestBody UserPo userPo, HttpServletRequest request) {
		Subject subject = SecurityUtils.getSubject();
		if (!subject.isAuthenticated()) {
			String password = userPo.getPassword();
			String header = request.getHeader("md5");
			if (header == null) {
				password = new SimpleHash(ShiroConfig.hashAlgorithm, password, ShiroConfig.credentialSalt, ShiroConfig.hashIterations).toString();
			}
			UsernamePasswordToken token = new UsernamePasswordToken(userPo.getUsername(), password);
			try {
				subject.login(token);
			} catch (UnknownAccountException e) {
				return BaseResponse.buildFailedResponse("用户名不正确");
			} catch (IncorrectCredentialsException ice) {
				return BaseResponse.buildFailedResponse("用户名或密码错误");
			} catch (LockedAccountException lae) {
				return BaseResponse.buildFailedResponse("该用户被禁用");
			} catch (LicenseExpirateException e) {
				return BaseResponse.buildFailedResponse("因授权过期系统已被锁定,请联系IT人员处理");
			} catch (AuthenticationException ae) {
				return BaseResponse.buildFailedResponse("登录失败");
			}
		}
		UserPo userInfo = (UserPo) Identity.getLoginIdentity();
		return BaseResponse.buildSuccessfulResponse(userInfo);
	}
	

	/**
	* 拼接 url,请求认证及授权
	*/
	@RequestMapping(value = "/login/iam-url", method = RequestMethod.GET)
	public BaseResponse<String> getIamUrl(@RequestParam String uri) {
		if (!StringUtils.hasLength(uri)) {
			return BaseResponse.buildFailedResponse("请求参数uri为空");
		}
		StringBuilder oaLoginUrl = new StringBuilder(256);
		String redirectUrl = new String();
		try {
			redirectUrl = oaLoginProperties.getRedirectUrl() + URLEncoder.encode(uri, StandardCharsets.UTF_8.name());
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException("URL编码失败", e);
		}
		String oaAuthLoginUrl = oaLoginProperties.getOaAuthLoginUrl().replace("{clientId}", oaLoginProperties.getClientId());
		oaLoginUrl.append(oaLoginProperties.getHost())
				  .append(oaAuthLoginUrl)
				  .append(redirectUrl);
		return BaseResponse.buildSuccessfulResponse(oaLoginUrl.toString());
	}
	
	/**
	* 根据授权码code,获取 access_token 并以此获取用户信息,登录系统返回用户信息
	*/
	@RequestMapping(value = "/login/iam-login", method = RequestMapping.POST)
	public BaseResponse<UserPo> iamLogin(@RequestBody String code, HttpServletReqeust request) {
		JSONObject josnObject = JSONObject.parseObject(code);
		String codeStr = jsonObject.getString("code");
		if (!StringUtils.hasLength()) {
			return BaseResponse.buildFailedResponse("请求参数code为空");
		}
		String accessTokenUrl = oaLoginProperties.getAccessTokenUrl().replace("{clientId}", oaLoginProperties.getClientId())
				.replace("{clientSecret}", oaLoginProperties.getClientSecret())
				.replace("{code}", codeStr);
		String oaUserLoginUrl = oaLoginProperties.getOaUserLoginUrl()
				.replace("{clientId}", oaLoginProperties.getClientId());
		// 1. iam 认证登录返回用户工号
		OaLoginDTO result = loginService.oaLogin(oaLoginProperties.getHost() + accessTokenUrl, oaLoginProperties.getHost() + oaUserLoginUrl);
		String userNo = result.getData();
		// 接口调用异常处理
		if (!StringUtils.hasLength(userNo)) {
			return BaseResponse.buildFailedResponse("【" + result.getErrCode() + "】" + result.getMessage() + "; " + result.getApiName() + "错误,请联系管理员处理。");
		}
		
		// 2. 通过工号去用户表查询用户是否存在,存在则登录,不存在返回提示
		User user = User.findByUserNo(userNo);
		if (Objects.isNull(user.getUserPo()) || user.getUserPo().getUserNo() == null) {
			return BaseResponse.buildFailedResponse("用户不存在");
		}
		UserPo userPo = user.getUserPo();
		
		// 3. 第三方系统登录
		Subject subject = SecurityUtils.getSubject();
		if (!subject.isAuthenticated()) {
			String password = userPo.getPassword(); // 注意:密码从数据库查询已经经过 md5 处理,不用再次使用 md5 哈希计算
			UsernamePasswordToken token = new UsernamePasswordToken(userPo.getUsername(), password);
			try {
				subject.login(token);
			} catch (UnknownAccountException e) {
				return BaseResponse.buildFailedResponse("用户名不正确");
			} catch (IncorrectCredentialsException ice) {
				return BaseResponse.buildFailedResponse("用户名或密码错误");
			} catch (LockedAccountException lae) {
				return BaseResponse.buildFailedResponse("该用户被禁用");
			} catch (LicenseExpirateException e) {
				return BaseResponse.buildFailedResponse("因授权过期系统已被锁定,请联系IT人员处理");
			} catch (AuthenticationException ae) {
				return BaseResponse.buildFailedResponse("登录失败");
			}
		}
		UserPo userInfo = (UserPo) Identity.getLoginIdentity();
		return BaseResponse.buildSuccessfulResponse(userInfo);
		
	
	}
	
	/**
	* 登出接口
	*/
	@RequestMapping(value = "/logout", method = RequestMehtod.GET)
	public BaseResponse logout() {
		boolean ret = loginService.logout();
		if (ret) {
			return BaseResponse.buildSuccessfulResponse(null);
		}
		return BaseResponse.buildFailedResponse("登出失败");
	}
}

服务接口 LoginService.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public interface LoginService {
	
	/**
	* IAM 登录获取用户信息
	* @param accessTokenUrl
	* @param oaUserLoginUrl
	*/
	OaLoginDTO oaLogin(String accessTokenUrl, String oaUserLoginUrl);
	
	/**
	* 用户登出
	*/
	boolean logout();
}

服务层 LoginServiceImpl.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Service
@Slf4j
public class LoginServiceImpl implements LoginService {

	@Override
	public boolean logout() {
		try {
			SecurityUtils.getSubject().logout();
		} catch (Exception e) {
			return false;
		}
		return true;
	}
	
	@Override
	public OaLoginDTO oaLogin(String accessTokenUrl, String oaUserLoginUrl) {
	
		log.info("accessTokenUrl : {}", accessTokenUrl);
		log.info("oaUserLoginUrl : {}", oaUserLoginUrl);
		// 1. 调用 iam access_token认证查询,返回 access_token
		Map<String, String> httpHeaderMap = new HashMap();
		httpHeaderMap.put("Accept", "application/json");
		String res = HttpUtil.doPost(accessTokenUrl, httpHeaderMap, "");
		log.info("iam access-token认证查询响应内容: {}", res);
		
		JSONObject resObj = JSONObject.parseObject(res);
		String accessToken = new String();
		accessToken = resObj.getString("access_token");
		// 处理错误响应
		if (!StringUtils.hasLength(accessToken)) {
			String errCode = new String();
			String message = new String();
			if (resObj.getString("code") != null) {
				errCode = resObj.getString("code");
				message = resObj.getString("message");
			} else {
				errCode = resObj.getString("errcode");
				message = resObj.getString("msg");
			}
			return OaLoginDTO.builder()
						.errCode(errCode)
						.message(message)
						.apiName("获取access_token接口")
						.build();
		}
		// 2. 获取用户信息查询,查询工号
		oaUserLoginUrl = oaUserLoginUrl.replace("{accessToken}", accessToken);
		res = HttpUtil.doGet(oaUserLoginUrl, httpHeaderMap, null);
		log.info("获取用户信息查询响应内容: {}", res);
		resObj = JSONObject.parseObject(res);
		String loginName = resObj.getString("loginName");
		if (!StringUtils.hasLength()) {
			String errCode = new String();
			String message = new String();
			if (resObj.getString("code") != null) {
				errCode = resObj.getString("code");
				message = resObj.getString("message");
			} else {
				errCode = resObj.getString("errcode");
				message = resObj.getString("msg");
			}
			return OaLoginDTO.builder()
						.errCode(errCode)
						.message(message)
						.apiName("获取用户信息接口")
						.build();
		}
		// 返回 loginName 即为工号,具体参考接口文档
		return OaLoginDTO.builder()
				.data(loginName)
				.build();
	}
}

OA 登录传输实体 OaLoginDTO.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Data
@Builder
public class OaLoginDTO {
	
	private String data;
	
	private String apiName;
	
	private String message;
	
	private String errCode;
}

附录

参考文献

版权信息

本文原载于kitebin.top,遵循CC BY-NC-SA 4.0协议,复制请保留原文出处。

CC BY-NC-ND
最后更新于 Nov 30, 2025 16:57 UTC
Built with Hugo
主题 StackJimmy 设计