Oauth2.0 简介
在详细介绍 Oauth2.0 之前,需要了解几个专用名词。它们对读懂后边的讲解至关重要。
- Third-party application: 第三方应用程序,又称为"客户端(client)"。
- HTTP Service: HTTP 服务提供商。
- Resource Owner: 资源所有者,本文称"用户"(user)。
- User Agent: 用户代理,本文指浏览器。
- Authorization server: 认证服务器,即服务提供商专门用来处理认证的服务器。
- Resource server: 资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。
了解以上名词,可以理解 OAuth 的作用就是让"客户端"安全可控地获取"用户"的授权,与"服务提供商"进行互动。
OAuth 的思路以及运行流程
OAuth 在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。“客户端"不能直接登录"服务提供商”,只能登录授权层,以此将用户与客户端区分。
“客户端"登录授权层所用令牌(token),与用户密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。

交互流程:
- (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 认证前,需提供:
- 应用系统名称
- 应用系统简称编码
- 应用系统访问地址
- 应用系统登出地址
IAM 统一认证身份系统管理员将上述信息注册完成后,会提供 client_id 和 client_secret 给应用系统。
功能联调
当双方功能开发完毕,统一在开发环境进行功能联调,功能没问题之后各自将配置、代码迁移到测试环境,进行业务 UAT 测试。
线上正式接入
业务测试环境通过之后,根据测试结果、双方协商上线节点、上线内容、上线步骤、遗留事项等,通过邮件确认,按计划在规定时间内上线。
认证集成环境示例
开发环境地址:https://iamdev.kitebin.top (需要本地配置 hosts: iamdev.kitebin.top 10.9.42.208)
测试环境地址:https://ssosit.kitebin.top
正式环境:上线时提供替换配置。
认证服务 Oauth2 协议(接口)
认证及单点登录流程

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协议,复制请保留原文出处。