上一篇文章介绍了 SSO 相关的基础数据,这样有了 ClientId 和密钥后,我们就要准备客户端这边的代码。客户端当前指的便是一个网站(也就是 RP),这个网站要求有会员功能,典型地网站导航上通常会有“注册”或“登录”的链接。
假设我们这是最简单的网站,采用 Servlet Session 本地记录用户凭证。本身这个网站没有设计用户的模块,得通过 SSO 完成用户登录。
RP 的 Yaml 配置文件如下:
user:
clientId: G5IFeG7Eesbny3f
clientSecret: J1Bb4zhchfziuDipKI7sgo6iyk
loginPage: http://127.0.0.1:8888/iam/oidc/authorization # 登录页面地址
另外我们还要准备一个登录页面,简单的例子如下。
这个页面存在 SSO 中心(也就是 OP)的,而不是客户端的。
标准 的 OIDC 流程
好,下面正式进入 OIDC 登录的流程,我们先大概了解一下整个流程。
OIDC 授权码模式的认证流程中涉及三方:用户、OIDC 服务器(OpenID Provider/OIDC Provider,简称 OP)、客户端业务应用(Relying Party,简称 Client)。用户、OP、RP 的交互目的分为以下几点:
- Client 希望用户登录,从而拿到用户身份信息。
- Client 发起登录,会跳转到 OP 的认证页面,OP 让用户登录,并授权自己的信息,然后 OP 将一个授权码(code)发给 Client。实际上这是在通过引用来传递用户信息。
- Client 收到授权码 code 后,结合 Client ID 和 Client Secret 到 OP 换取该用户的 access_token。
- Client 利用 access_token 到 OP 去获取用户的相关信息,从而得到一个可信的身份断言,让用户登录。
下面我们再逐一详解。
客户端发起登录
一开始,用户并未在客户端网站上登录。当用户点击“登录”按钮要求登录的时候,RP(客户端)的后台接口/user/login
会组装所需的参数,形成 URI 返回303 Location=该 URI
,告诉浏览器重定向到 OP /oidc/authorization
。
这是 RP 的接口,执行上述过程。
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.RedirectView;
import com.ajaxjs.util.StrUtil;
@RestController
@RequestMapping("/user")
public class UserController {
@Value("${user.loginPage}")
private String userLoginCode;
@Value("${user.clientId}")
private String clientId;
@GetMapping("/login")
public ModelAndView get() {
String url = userLoginCode + "?response_type=code&client_id=" + clientId;
url += "&redirect_uri=http://localhost:8080/mywebsite/user/auth_code";
url += "&state=" + StrUtil.getRandomString(6);
return new ModelAndView(new RedirectView(url));
}
}
浏览器的重定向,即返回:
HTTP/1.1 303 See Other
Location: http://127.0.0.1:8888/iam/oidc/authorization
?redirect_uri=http://localhost:8080/mywebsite/user/auth_code
&response_type=code
&state=J1Bb4zhchfziuDipKI7sgo6iyk
&client_id=G5IFeG7Eesbny3f
参数说明
参数 | 说明 |
---|---|
redirect_uri | 用户登录成功后,OP 回传授权码等信息给RP的接口,相当于回调地址 |
response_type | 固定值 code,表示授权码流程 |
client_id | RP 在 OP 注册的 client_id |
state | 不透明字符串,当 OP 重定向到 redirect_uri 时,会原样返回给 RP,用于防止 CSRF、 XSRF。由于 OP 会原样返回此参数,可将 state 值与用户在 RP 登录前最后浏览的 URI 绑定,便于登录完成后将用户重定向回最后浏览的页面 |
发送授权码
OP 端的/oidc/authorization
接口里有这么两个分支的判断:
- 发现用户未登录,返回 HTTP 303,通过浏览器重定向到登录页面
- 用户已登录,于是执行授权逻辑,签发授权码
判断是否已登录
怎么判断没有登录?没有携带特定的 cookie 便是表明用户未登录。一般 Java Web 的是 JSESSIONID,这样的话可以在后台直接使用 Session 对象(比较简单,但只是“单机”)。如果是 Redis 方案,也要使用 Cookie,并且要把 Cookie 与 Redis 缓存对应起来。
参考代码如下。
接口定义。
/**
* 1、发现用户未登录,返回 303,通过浏览器重定向到登录页面
* 2、用户已登录,于是执行授权逻辑,签发授权码
*
* @param req 请求对象
* @param resp 响应对象
*/
@GetMapping("/authorization")
void authorization(
@RequestParam("response_type") String responseType,
@RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUri, @RequestParam(required = false) String scope, @RequestParam String state,
HttpServletRequest req, HttpServletResponse resp);
用户登录
若是未登录则跳到登录页面,则是/iam/login/?response_type=code&client_id=G5IFeG7Eesbny3f&redirect_uri=http://localhost:8080/mywebsite/user/auth_code&state=tMpnBJ
。
我们在跳转的时候加入一个提示的页面,让用户感知更好。
用户输入账密,表单 AJAX 提交时触发 OP 验证账密接口 POST op.com/user/login
。下面是一个比较简单的例子,注意要附上所有的 QueryString 参数(location.search
)。
如果账密正确则重定向回 OP 授权接口(返回 303 Location=步骤1 的 Location,即/oidc/authorization
),并设置 OP 的会话状态(设置 cookie)。
发送授权码
此时再度进入/oidc/authorization
,发现用户成功登录了于是就可以发送授权码,返回303 Location=redirect_uri,浏览器重定向到 redirect_uri。这样 RP 就可以得到授权码。
接口定义(跟前面的一样)。
/**
* 1、发现用户未登录,返回 303,通过浏览器重定向到登录页面
* 2、用户已登录,于是执行授权逻辑,签发授权码
*
* @param req 请求对象
* @param resp 响应对象
*/
@GetMapping("/authorization")
void authorization(
@RequestParam("response_type") String responseType,
@RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUri, @RequestParam(required = false) String scope, @RequestParam String state,
HttpServletRequest req, HttpServletResponse resp);
生成授权码的规则是 clientId 加时间戳再 SHA1 得到 code,注意要把 code 和当前用户对应起来,然后存到 5 分钟超时的缓存中。
返回给 RP 类似于这样的响应:
HTTP/1.1 303 See Other
Location: http://rp.com/user/callback
?state=DJOfvYDSDxaPzOKR
&code=Z0FBQUFBQmVjc
为什么 OIDC 授权码流程要 code 换 token 再换用户信息?
答案出处。
OIDC 协议中,用户登录成功后,OIDC 认证服务器会将用户的浏览器回调到一个回调地址,并携带一个授权码(code)。此授权码一般有效期十分钟且一次有效,用后作废。这避免了在前端暴露 access_token 或者用户信息的风险,access_token 的有效期都比较长,一般为 1~2 个小时。如果泄露会对用户造成一定影响。
后端收到这个 code 之后,需要使用 Client Id + Client Secret + Code 去 OIDC 认证服务器换取用户的 access_token。在这一步,实际上 OIDC Server 对 OAuth Client 进行了认证,能够确保来 OIDC 认证服务器获取 access_token 的机器是可信任的,而不是任何一个人拿到 code 之后都能来 OIDC 认证服务器进行 code 换 token。
如果 code 被黑客获取到,如果他没有 Client Id + Client Secret 也无法使用,就算有,也要和真正的应用服务器竞争,因为 code 一次有效,用后作废,加大了攻击难度。相反,如果不经过 code 直接返回 access_token 或用户信息,那么一旦泄露就会对用户造成影响。
获取 Token
RP 后台请求 Token
RP 接收到 OP 回传的 code 参数后,接着就是向 OP /oidc/token
接口获取 AccessToken 令牌,也就是用 code 换 token。注意这是在服务端请求完成的(此处使用 Spring RestTemplate 请求)。
其中请求头Authorization
字段通过Basic
关键字传递 RP 在 OP 注册的client_id
和client_secret
。请见下面源码:
@Value("${user.tokenApi}")
private String tokenApi;
@RequestMapping("/callback")
public void token(@RequestParam String code, @RequestParam(required = false) String state) {
RestTemplate restTemplate = new RestTemplate();
// restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret));// basic 认证
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth(clientId, clientSecret);
MultiValueMap<String, String> bodyParams = new LinkedMultiValueMap<>();
bodyParams.add("grant_type", "authorization_code");
bodyParams.add("code", code);
bodyParams.add("state", state);
ResponseEntity<String> responseEntity = restTemplate.exchange(tokenApi, HttpMethod.POST,
new HttpEntity<>(bodyParams, headers), String.class);
System.out.println(responseEntity);
// TODO 获取 Token 的后续工作……
// if (responseEntity.getStatusCode().is2xxSuccessful()) {
// // 处理授权成功的逻辑,例如解析并保存访问令牌和刷新令牌等
// return "success";
// } else {
// // 处理授权失败的逻辑
// return "error";
// }
}
具体传参方式是将client_id
和client_secret
通过 ‘:
’ 号拼接,并使用 Base64 进行编码得到字符串。将此编码字符串放到请求头 Authorization 去发送请求。不过借助框架的封装headers.setBasicAuth(clientId, clientSecret)
,很简单就完成了。这个封装相当于下面的逻辑:
public void requestWithBasic(String clientId, String clientSecret) {
String clientAndSecret = clientId + ":" + clientSecret;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("Authorization", "Basic " + StrUtil.base64Encode(clientAndSecret)); // 请求头
MultiValueMap<String, Object> bodyParams = new LinkedMultiValueMap<>();
bodyParams.add("grant_type", "client_credentials");
ResponseEntity<String> responseEntity = restTemplate.exchange(tokenEndPoint, HttpMethod.POST, new HttpEntity<>(bodyParams, headers), String.class);
if (responseEntity.getStatusCode().is2xxSuccessful()) {
}
}
好,我们看看正式请求是怎么样子的:
POST op.com//oidc/token
Authorization: Basic cUZFeFZtQlE4blBZOjVjNWVkYjA2OTA2MTZjZGJkNGNmOWMwYjBlMjg3MWVkNjM2MzE2Z
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&state=DJOfvYDSDxaPzOKRoyaT
&code=Z0FBQUFBQm
有文章说要传redirect_uri
参数,——我这是在服务器请求,跳转对我没用呀,——不知为何要传呢?而且这个接口已经返回 token 给 RP 了,要跳转也是我 RP 的事情呀。
最后,获取 Token 的后续工作……这里是个服务端的请求,暂且不表,看看 OP 如何颁发 Token 先。
OP 颁发 Token
颁发 Token 是/oidc/token
,定义如下:
/**
* 获取 Token
*
* @param authorization client 信息
* @param code 授权码
* @param state 不透明字符串
* @param grantType 授权码流程
* @return 令牌 Token
*/
@PostMapping("/token")
Result<JwtAccessToken> token(@RequestHeader String authorization, @RequestParam String code, @RequestParam String state, @RequestParam("grant_type") String grantType);
要实现的逻辑如下:
- 校验 RP 在请求头
authorization
字段通过 HTTP Basic 认证传入的client_id
和client_secret
- 从缓存中,根据 code 获取对应的用户
- 如果都校验通过,则生成 access token、id token 并返回
见OidcService
源码:
返回 JSON 如下例子:
HTTP/1.1 200 OK
Content-Type: application/json
{
"state": "DJOfvYDSDxaPzOKRoyaTaQWCoWywdeKU",
"scope": "openid profile email address phone",
"access_token":"Z0FBQUFBQmVjdWxLcFBWVn",
"token_type": "Bearer",
"id_token":"eyJhbGciOiJSUzI1NiIsIm……"
}
RP 构建自身的会话状态
RP 构建自身的会话状态(设置一个 cookie,表明用户已在 RP 登录)。找出 state 绑定的 URI ,将用户重定向回登录前最后浏览的页面。
@RequestMapping("/callback")
public ModelAndView token(@RequestParam String code, @RequestParam(required = false) String state,
HttpSession session) {
// restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret));// basic 认证
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth(clientId, clientSecret);
MultiValueMap<String, String> bodyParams = new LinkedMultiValueMap<>();
bodyParams.add("grant_type", "authorization_code");
bodyParams.add("code", code);
bodyParams.add("state", state);
ResponseEntity<JwtAccessToken> responseEntity = getRestTemplate().exchange(getTokenApi(), HttpMethod.POST,
new HttpEntity<>(bodyParams, headers), new ParameterizedTypeReference<JwtAccessToken>() {
});
// ResponseEntity<String> responseEntity = getRestTemplate().exchange(getTokenApi(), HttpMethod.POST,
// new HttpEntity<>(bodyParams, headers), String.class);
if (responseEntity.getStatusCode().is2xxSuccessful()) {// 处理授权成功的逻辑,例如解析并保存访问令牌和刷新令牌等
onAccessTokenGot(responseEntity.getBody(), session);
return new ModelAndView("redirect:/");
} else {
System.out.println(responseEntity);
// 处理授权失败的逻辑
throw new SecurityException("获取 JWT Token 是吧");
}
}
@Value("${user.jwtSecretKey}")
private String jwtSecretKey;
@Bean
JWebTokenMgr jWebTokenMgr() {
JWebTokenMgr mgr = new JWebTokenMgr();
mgr.setSecretKey(jwtSecretKey);
return mgr;
}
@Override
public void onAccessTokenGot(JwtAccessToken token, HttpSession session) {
String idToken = token.getId_token();
JWebTokenMgr mgr = jWebTokenMgr();
JWebToken jwt = mgr.parse(idToken);
if (mgr.isValid(jwt)) {
User user = new User();
user.setId(Long.parseLong(jwt.getPayload().getSub()));
user.setName(jwt.getPayload().getName());
AccessToken accessToken = new AccessToken();
BeanUtils.copyProperties((AccessToken) token, accessToken);
user.setAccessToken(accessToken);
session.setAttribute(UserLogined.USER_IN_SESSION, user);
} else
throw new SecurityException("返回非法 JWT Token");
}
RP 整合 SSO,实际上有 SDK 提供的,是为 aj-iam-client。
获取用户信息
JWT 所包含的用户信息当前只有 userId 和 userName,若要获取用户更多的信息如手机号码的,可以查询用户信息接口。使用 Access Token 在后台向 OP 的用户详情接口GET op.com/userinfo
发请求,获取用户详细信息。其中请求头authorization
字段使用Bearer
关键字传递 Access Token,注意这是 AccessToken 而非 JWT Token。
GET op.com/userinfo
authorization: Bearer Z0FBQUFBQmVjdHFwcW1Xc08ybG9BaG5PRGNJSTR3alFjTjJEcEF4aVl3VDZCMW5OTmFhOXdZe
小结
基本上 SSO 就可以这样跑起来了。但是一个完整 SSO 系统还有很多地方需要去完善,比如 JWT 密钥的交换,用户注销等待——我们下一节再讲!
AJ-IAM 源码:https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-backend/aj-iam
参考
推荐下面文章,我也是跟他们学习的。
- 基于 OIDC 实现单点登录 SSO、第三方登录 https://blog.csdn.net/u012324798/article/details/105612729 (强烈推荐)
- 理解 OIDC 与 OAuth2.0 协议 https://blog.csdn.net/ruanchengshen/article/details/129166819,出处。授权码只是其中一个模式,还有其他不同的模式以适应不同的场景。