【Spring Security】 Oauth2 授权页面
【Spring Security】 Oauth2 授权页面
Metadata
title: 【Spring Security】 Oauth2 授权页面
date: 2023-02-05 16:00
tags:
- 行动阶段/完成
- 主题场景/组件
- 笔记空间/KnowladgeSpace/ProgramSpace/ModuleSpace
- 细化主题/Module/SpringSecurity
categories:
- SpringSecurity
keywords:
- SpringSecurity
description: 【Spring Security】 Oauth2 授权页面
【Spring Security】 Oauth2 授权页面
之前分析了 auth_client_details 表 scope、resource_ids、authorities 这些字段,然后还有一个 autoapprove 字段,它和 scope 授权有关,接下来简单分析下。
获取授权码源码分析
http://localhost:20000/oauth/authorize?client_id=client&client_secret=secret&response_type=code
1. 进入 BasicAuthenticationFilter
和密码模式一样,首先都会进入 BasicAuthenticationFilter 过滤器对客户端进行认证。
然后经过其他过滤器,如果用户没有登录,则会调转到首页进行登录。
2. 进入 AuthorizationEndpoint
客户端及用户都认证通过后,进入到 AuthorizationEndpoint 端点,申请授权码:
@RequestMapping(value = "/oauth/authorize")
public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
SessionStatus sessionStatus, Principal principal) {}
传入的参数有:
- model:页面模型数据,客户端相关信息
- parameters:请求参数
- principal:用户认证信息
/oauth/authorize
端点的源码说明如下:
@RequestMapping(value = "/oauth/authorize")
public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
SessionStatus sessionStatus, Principal principal) {
// 1. 创建认证请求,包含了请求参数和客户端相关信息
AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);
// 2. 获取响应类型=》code
Set<String> responseTypes = authorizationRequest.getResponseTypes();
// 3. 不是token 和code,则抛出异常
if (!responseTypes.contains("token") && !responseTypes.contains("code")) {
throw new UnsupportedResponseTypeException("Unsupported response types: " + responseTypes);
}
// 4. 没有客户端 ,则抛出异常
if (authorizationRequest.getClientId() == null) {
throw new InvalidClientException("A client id must be provided");
}
try {
// 5. 没有登录信息,抛出异常
if (!(principal instanceof Authentication) || !((Authentication) principal).isAuthenticated()) {
throw new InsufficientAuthenticationException(
"User must be authenticated with Spring Security before authorization can be completed.");
}
// 6. 数据库查询客户端信息
ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId());
// 7. 获取请求参数中的redirect_uri,
String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI);
// 8. 解析回调地址,如果数据库中没有配置redirect_uri,报错
// 数据库中配置了,只有一个,则直接返回
// 配置了多个,则查看请求中的是否和数据库中的匹配,不匹配则报错
String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client);
if (!StringUtils.hasText(resolvedRedirect)) {
throw new RedirectMismatchException(
"A redirectUri must be either supplied or preconfigured in the ClientDetails");
}
// 9. 设置回调地址
authorizationRequest.setRedirectUri(resolvedRedirect);
// 10. 校验授权范围
oauth2RequestValidator.validateScope(authorizationRequest, client);
// 11. 检查是否授权,设置了自动授权,存储中也有,则设置scope授权成功,没有则会认为没有scope授权
authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest,
(Authentication) principal);
// 12. 是否scope授权了。
boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
authorizationRequest.setApproved(approved);
// 13. 授权了,则直接返回code
if (authorizationRequest.isApproved()) {
if (responseTypes.contains("token")) {
return getImplicitGrantResponse(authorizationRequest);
}
if (responseTypes.contains("code")) {
return new ModelAndView(getAuthorizationCodeResponse(authorizationRequest,
(Authentication) principal));
}
}
// 14. 没有授权,创建ModelAndView 数据Model封装
model.put(AUTHORIZATION_REQUEST_ATTR_NAME, authorizationRequest);
model.put(ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME, unmodifiableMap(authorizationRequest));
// 15. 返回授权页面
return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal);
}
catch (RuntimeException e) {
sessionStatus.setComplete();
throw e;
}
}
在 checkForPreApproval 方法方法中,会对 scope 是否自动授权进行校验,然后保存相关信息。
public AuthorizationRequest checkForPreApproval(AuthorizationRequest authorizationRequest,
Authentication userAuthentication) {
String clientId = authorizationRequest.getClientId(); // client
Collection<String> requestedScopes = authorizationRequest.getScope(); // scope
Set<String> approvedScopes = new HashSet<String>();
Set<String> validUserApprovedScopes = new HashSet<String>();
if (clientDetailsService != null) {
try {
ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
// 1. 查询client 配置了自动授权的 scope
for (String scope : requestedScopes) {
if (client.isAutoApprove(scope)) {
approvedScopes.add(scope);
}
}
// 2. 如果客户端的授权范围scope 都是自动授权的
if (approvedScopes.containsAll(requestedScopes)) {
// gh-877 - if all scopes are auto approved, approvals still need to be added to the approval store.
Set<Approval> approvals = new HashSet<Approval>();
// 3. 获取 UserApprovalHandler 配置的授权过期时间,默认一个月
Date expiry = computeExpiry();
// 4. 添加客户端,用户授权,过期时间等数据
for (String approvedScope : approvedScopes) {
approvals.add(new Approval(userAuthentication.getName(), authorizationRequest.getClientId(),
approvedScope, expiry, ApprovalStatus.APPROVED));
}
// 5. 添加到存储中,默认是没有实现的,也就是没有持久化
approvalStore.addApprovals(approvals);
// 6. 设置已范围授权
authorizationRequest.setApproved(true);
return authorizationRequest;
}
}
catch (ClientRegistrationException e) {
logger.warn("Client registration problem prevent autoapproval check for client=" + clientId);
}
}
if (logger.isDebugEnabled()) {
StringBuilder builder = new StringBuilder("Looking up user approved authorizations for ");
builder.append("client_id=" + clientId);
builder.append(" and username=" + userAuthentication.getName());
logger.debug(builder.toString());
}
// 7. 没有自动授权,则查询存储,设置授权,存储没有,返回没有scope授权标记
// Find the stored approvals for that user and client
Collection<Approval> userApprovals = approvalStore.getApprovals(userAuthentication.getName(), clientId);
// Look at the scopes and see if they have expired
Date today = new Date();
for (Approval approval : userApprovals) {
if (approval.getExpiresAt().after(today)) {
if (approval.getStatus() == ApprovalStatus.APPROVED) {
validUserApprovedScopes.add(approval.getScope());
approvedScopes.add(approval.getScope());
}
}
}
if (logger.isDebugEnabled()) {
logger.debug("Valid user approved/denied scopes are " + validUserApprovedScopes);
}
// If the requested scopes have already been acted upon by the user,
// this request is approved
if (validUserApprovedScopes.containsAll(requestedScopes)) {
approvedScopes.retainAll(requestedScopes);
// Set only the scopes that have been approved by the user
authorizationRequest.setScope(approvedScopes);
authorizationRequest.setApproved(true);
}
return authorizationRequest;
}
3. scope 授权页面
最终因为没有自动授权,则会调转到授权页面,返回 ModelAndView 对象,其中 Model 对象包含以下数据。
可以看到 ModelAndView,请求转发到了一个内部地址。
最终弹出了 scope 授权页面。
当我们选了则授权范围点击后,重新进入到 / oauth/authorize 端点,因为已经手动授权了,所以会进入 getAuthorizationCodeResponse 方法,进行授权码的发放。
private View getAuthorizationCodeResponse(AuthorizationRequest authorizationRequest, Authentication authUser) {
try {
// 1. 获取授权嘛,并重定向到回调地址
return new RedirectView(getSuccessfulRedirect(authorizationRequest,
generateCode(authorizationRequest, authUser)), false, true, false);
}
catch (OAuth2Exception e) {
return new RedirectView(getUnsuccessfulRedirect(authorizationRequest, e, false), false, true, false);
}
}
generateCode 方法,会生成一个随机的授权码,并保存到内存中。
private String generateCode(AuthorizationRequest authorizationRequest, Authentication authentication)
throws AuthenticationException {
try {
OAuth2Request storedOAuth2Request = getOAuth2RequestFactory().createOAuth2Request(authorizationRequest);
OAuth2Authentication combinedAuth = new OAuth2Authentication(storedOAuth2Request, authentication);
// 生成授权码并保存,默认InMemoryAuthorizationCodeServices生成
String code = authorizationCodeServices.createAuthorizationCode(combinedAuth);
return code;
}
catch (OAuth2Exception e) {
if (authorizationRequest.getState() != null) {
e.addAdditionalInformation("state", authorizationRequest.getState());
}
throw e;
}
}
最终,浏览器重定向到回调地址,就收到了授权码了。。
应用案例
案例 1 自动授权(配置 autoapprove 为 true)
首先我们配置 autoApprove 为 true。
然后采用授权码进行访问,发现没有跳出授权页面,那么配置 autoapprove 为 true 时,表示会自动授权,不会弹出授权页面。
案例 2 设置自动授权范围
可以对客户端配置自动授权的范围,如果没有配置拥有的所有授权范围 ,还是会弹出授权页面。
案例 3 自定义授权页面
默认的 的页面地址是 / oauth/confirm_access ,我们可以看下默认页面是怎么写的。
@FrameworkEndpoint
@SessionAttributes("authorizationRequest")
public class WhitelabelApprovalEndpoint {
@RequestMapping("/oauth/confirm_access")
public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
final String approvalContent = createTemplate(model, request);
if (request.getAttribute("_csrf") != null) {
model.put("_csrf", request.getAttribute("_csrf"));
}
View approvalView = new View() {
@Override
public String getContentType() {
return "text/html";
}
@Override
public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType(getContentType());
response.getWriter().append(approvalContent);
}
};
return new ModelAndView(approvalView, model);
}
protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
String clientId = authorizationRequest.getClientId();
StringBuilder builder = new StringBuilder();
builder.append("<html><body><h1>OAuth Approval</h1>");
builder.append("<p>Do you authorize \"").append(HtmlUtils.htmlEscape(clientId));
builder.append("\" to access your protected resources?</p>");
builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");
String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
if (requestPath == null) {
requestPath = "";
}
builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");
String csrfTemplate = null;
CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
if (csrfToken != null) {
csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
"\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
}
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"Authorize\" type=\"submit\"/></label></form>";
if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
builder.append(createScopes(model, request));
builder.append(authorizeInputTemplate);
} else {
builder.append(authorizeInputTemplate);
builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
builder.append("<label><input name=\"deny\" value=\"Deny\" type=\"submit\"/></label></form>");
}
builder.append("</body></html>");
return builder.toString();
}
private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
StringBuilder builder = new StringBuilder("<ul>");
@SuppressWarnings("unchecked")
Map<String, String> scopes = (Map<String, String>) (model.containsKey("scopes") ?
model.get("scopes") : request.getAttribute("scopes"));
for (String scope : scopes.keySet()) {
String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
scope = HtmlUtils.htmlEscape(scope);
builder.append("<li><div class=\"form-group\">");
builder.append(scope).append(": <input type=\"radio\" name=\"");
builder.append(scope).append("\" value=\"true\"").append(approved).append(">Approve</input> ");
builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
builder.append(denied).append(">Deny</input></div></li>");
}
builder.append("</ul>");
return builder.toString();
}
}
我们则只需要自己定义一个oauth/confirm_access
,覆盖掉原来的就可以了。
@Controller
@SessionAttributes("authorizationRequest")
public class PearlApprovalEndpoint {
@RequestMapping(value = "/oauth/confirm_access", produces = "text/html;charset=UTF-8")
public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
final String approvalContent = createTemplate(model, request);
if (request.getAttribute("_csrf") != null) {
model.put("_csrf", request.getAttribute("_csrf"));
}
View approvalView = new View() {
@Override
public String getContentType() {
return "text/html;charset=utf-8";
}
@Override
public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType(getContentType());
response.getWriter().append(approvalContent);
}
};
return new ModelAndView(approvalView, model);
}
protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
String clientId = authorizationRequest.getClientId();
StringBuilder builder = new StringBuilder();
builder.append("<html><body><h1>OAuth 授权</h1>");
builder.append("<p>是否允许当前网站 \"").append(HtmlUtils.htmlEscape(clientId));
builder.append("\" 访问受保护的资源 ?</p>");
builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");
String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
if (requestPath == null) {
requestPath = "";
}
builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");
String csrfTemplate = null;
CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
if (csrfToken != null) {
csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
"\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
}
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"点击授权\" type=\"submit\"/></label></form>";
if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
builder.append(createScopes(model, request));
builder.append(authorizeInputTemplate);
} else {
builder.append(authorizeInputTemplate);
builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
builder.append("<label><input name=\"deny\" value=\"拒绝\" type=\"submit\"/></label></form>");
}
builder.append("</body></html>");
return builder.toString();
}
private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
StringBuilder builder = new StringBuilder("<ul>");
@SuppressWarnings("unchecked")
Map<String, String> scopes = (Map<String, String>) (model.containsKey("scopes") ?
model.get("scopes") : request.getAttribute("scopes"));
for (String scope : scopes.keySet()) {
String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
scope = HtmlUtils.htmlEscape(scope);
builder.append("<li><div class=\"form-group\">");
builder.append(scope).append(": <input type=\"radio\" name=\"");
builder.append(scope).append("\" value=\"true\"").append(approved).append(">授权访问</input> ");
builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
builder.append(denied).append(">拒绝</input></div></li>");
}
builder.append("</ul>");
return builder.toString();
}
}
可以看到授权页面变成了我们自定义的: