[原創]Oauth2.0實現SSO單點登陸的CAS方式和相關Demo演示

SSO介紹

什麼是SSO

百科:SSO英文全稱Single Sign On,單點登陸。SSO是在多個應用系統中,用戶只須要登陸一次就能夠訪問全部相互信任的應用系統。它包括能夠將此次主要的登陸映射到其餘應用中用於同一個用戶的登陸的機制。它是目前比較流行的企業業務整合的解決方案之一。css

簡單來講,SSO出現的目的在於解決同一產品體系中,多應用共享用戶session的需求。SSO經過將用戶登陸信息映射到瀏覽器cookie中,解決其它應用免登獲取用戶session的問題。html

爲何須要SSO

開放平臺業務自己不須要SSO,可是若是平臺的普通用戶也能夠在申請後成爲一個應用開發者,那麼就須要將平臺加入到公司的總體帳號體系中去,另外,對於企業級場景來講,通常都會有SSO系統,充當統一的帳號校驗入口。java

CAS協議中概念介紹

SSO單點登陸只是一個方案,而目前市面上最流行的單端登陸系統是由耶魯大學開發的CAS系統,而由其實現的CAS協議,也成爲目前SSO協議中的既定協議,下文中的單點登陸協議及結構,均爲CAS中的體現結構
CAS協議中有如下幾個概念:
1.CAS Client:須要集成單點登陸的應用,稱爲單點登陸客戶端
2.CAS Server:單點登陸服務器,用戶登陸鑑權、憑證下發及校驗等操做
3.TGT:ticker granting ticket,用戶憑證票據,用以標記用戶憑證,用戶在單點登陸系統中登陸一次後,再其有效期內,TGT即表明用戶憑證,用戶在其它client中無需再進行二次登陸操做,便可共享單點登陸系統中的已登陸用戶信息
4.ST:service ticket,服務票據,服務能夠理解爲客戶端應用的一個業務模塊,體現爲客戶端回調url,CAS用以進行服務權限校驗,即CAS能夠對接入的客戶端進行管控
5.TGC:ticket granting cookie,存儲用戶票據的cookie,即用戶登陸憑證最終映射的cookiesgit

CAS核心協議介紹

image.png

1.用戶在瀏覽器中訪問應用 2.應用發現須要索要用戶信息,跳轉至SSO服務器 3.SSO服務器向用戶展現登陸界面,用戶進行登陸操做,SSO服務器進行用戶校驗後,映射出TGC 4.SSO服務器向回調應用服務url,返回ST 5.應用去SSO服務器校驗ST權限及合法性 6.SSO服務器校驗成功後,返回用戶信息github

CAS基本流程介紹

如下爲基本的CAS協議流程,圖一爲初次登陸時的流程,圖二爲已進行過一次登陸後的流程web

image.png

image.png

以上是oauth的單點登陸的流程,下面咱們來看下應該如何配置單點登陸:spring

繼承了WebSecurityConfigurerAdapter的類上加@EnableOAuth2Sso註解來表示支持單點登陸:express

@Configuration
@EnableOAuth2Sso
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {}

另外還須要在應用中添加以下的兩個類:apache

SsoApprovalEndpoint:瀏覽器

package urity.demo.sso;

import org.apache.catalina.util.ParameterMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

@RestController
@SessionAttributes("authorizationRequest")
public class SsoApprovalEndpoint {

    @RequestMapping("/oauth/confirm_access")
    public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
        String template = createTemplate(model, request);
        if (request.getAttribute("_csrf") != null) {
            model.put("_csrf", request.getAttribute("_csrf"));
        }
        return new ModelAndView(new SsoSpelView(template), model);
    }

    protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
        //解決從登陸跳轉到受權 和 應用之間跳轉受權 form表單內action值相同 致使沒法完成受權的問題
        if((request.getParameterMap()) instanceof ParameterMap){
            this.DENIAL="<form id='denialForm' name='denialForm' action='${path}/oauth/authorize' method='post' style='display: inline-block;margin-left: 15px; ' ><input name='user_oauth_approval' value='false' type='hidden'/>%csrf%<label><input class='sub' name='deny' value='取消' type='submit' /></label></form>";
            this.TEMPLATE="<html><head><style type='text/css'>.sub{width: 100px;background: grey;color: #fff;transition: all 1s;}.sub:hover{background-color: #FFF;color: black;transition: all 1s;transition: all 1s;}"
                    +"</style></head><body style='background-color: #eee;'><div style='text-align: center; margin-top: 35px;'><h1>認證受權</h1>"
                    + "<p>你肯定受權應用 '【${authorizationRequest.clientId}】' 登陸並訪問你的信息?</p>"
                    + "<form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post' style='display: inline-block;margin-right: 15px;  ' ><input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input class='sub' name='authorize' value='肯定' type='submit' /></label></form>"
                    + "%denial%</div></body></html>";
        }else{
            this.DENIAL="<form id='denialForm' name='denialForm' action='/oauth/authorize' method='post' style='display: inline-block;margin-left: 15px; ' ><input name='user_oauth_approval' value='false' type='hidden'/>%csrf%<label><input class='sub' name='deny' value='取消' type='submit' /></label></form>";
            this.TEMPLATE="<html><head><style type='text/css'>.sub{width: 100px;background: grey;color: #fff;transition: all 1s;}.sub:hover{background-color: #FFF;color: black;transition: all 1s;}"
                    +"</style></head><body style='background-color: #eee;'><div style='text-align: center; margin-top: 35px;'><h1>認證受權</h1>"
                    + "<p>你肯定受權應用 '【${authorizationRequest.clientId}】' 登陸並訪問你的信息?</p>"
                    + "<form id='confirmationForm' name='confirmationForm' action='/oauth/authorize' method='post' style='display: inline-block;margin-right: 15px;  ' ><input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input class='sub' name='authorize' value='肯定' type='submit' /></label></form>"
                    + "%denial%</div></body></html>";
        }

        String template = TEMPLATE;
        if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
            template = template.replace("%scopes%", createScopes(model, request)).replace("%denial%", "");
        }
        else {
            template = template.replace("%scopes%", "").replace("%denial%", DENIAL);
        }
        if (model.containsKey("_csrf") || request.getAttribute("_csrf") != null) {
            template = template.replace("%csrf%", CSRF);
        }
        else {
            template = template.replace("%csrf%", "");
        }
        return template;
    }

    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" : "";
            String value = SCOPE.replace("%scope%", scope).replace("%key%", scope).replace("%approved%", approved)
                    .replace("%denied%", denied);
            builder.append(value);
        }
        builder.append("</ul>");
        return builder.toString();
    }

    private static String CSRF = "<input type='hidden' name='${_csrf.parameterName}' value='${_csrf.token}' />";

    private  String DENIAL = "<form id='denialForm' name='denialForm' action='/oauth/authorize' method='post' style='display: inline-block;margin-left: 15px; ' ><input name='user_oauth_approval' value='false' type='hidden'/>%csrf%<label><input class='sub' name='deny' value='取消' type='submit' /></label></form>";

    //    private static String TEMPLATE = "<html><body><div style='display:none;'><h1>OAuth Approval</h1>"
//            + "<p>Do you authorize '${authorizationRequest.clientId}' to access your protected resources?</p>"
//            + "<form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post'><input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input name='authorize' value='Authorize' type='submit'/></label></form>"
//            + "%denial%</div><script>document.getElementById('confirmationForm').submit()</script></body></html>";
    private  String TEMPLATE = "<html><head><style type='text/css'>.sub{width: 100px;background: grey;color: #fff;}.sub:hover{background-color: #FFF;color: black;}"
            +"</style></head><body style='background-color: #eee;'><div style='text-align: center; margin-top: 35px;'><h1>認證受權</h1>"
            + "<p>你肯定受權應用 '【${authorizationRequest.clientId}】' 登陸並訪問你的信息?</p>"
            + "<form id='confirmationForm' name='confirmationForm' action='/oauth/authorize' method='post' style='display: inline-block;margin-right: 15px;  ' ><input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input class='sub' name='authorize' value='肯定' type='submit' /></label></form>"
            + "%denial%</div></body></html>";


    private static String SCOPE = "<li><div class='form-group'>%scope%: <input type='radio' name='%key%'"
            + " value='true'%approved%>Approve</input> <input type='radio' name='%key%' value='false'%denied%>Deny</input></div></li>";



}

SsoSpelView:

package urity.demo.sso;

import org.springframework.context.expression.MapAccessor;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.security.oauth2.common.util.RandomValueStringGenerator;
import org.springframework.util.PropertyPlaceholderHelper;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;

public class SsoSpelView implements View {
    private final String template;
    private final String prefix;
    private final SpelExpressionParser parser = new SpelExpressionParser();
    private final StandardEvaluationContext context = new StandardEvaluationContext();
    private PropertyPlaceholderHelper.PlaceholderResolver resolver;

    public SsoSpelView(String template) {
        this.template = template;
        this.prefix = new RandomValueStringGenerator().generate() + "{";
        this.context.addPropertyAccessor(new MapAccessor());
        this.resolver = new PropertyPlaceholderHelper.PlaceholderResolver() {
            public String resolvePlaceholder(String name) {
                Expression expression = parser.parseExpression(name);
                Object value = expression.getValue(context);
                return value == null ? null : value.toString();
            }
        };
    }

    public String getContentType() {
        return "text/html";
    }

    public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        Map<String, Object> map = new HashMap<String, Object>(model);
        String path = ServletUriComponentsBuilder.fromContextPath(request).build()
                .getPath();
        map.put("path", (Object) path==null ? "" : path);
        context.setRootObject(map);
        String maskedTemplate = template.replace("${", prefix);
        PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper(prefix, "}");
        String result = helper.replacePlaceholders(maskedTemplate, resolver);
        result = result.replace(prefix, "${");
        response.setContentType(getContentType());
        response.getWriter().append(result);
    }

}

分析:SsoApprovalEndpoint這個類的來源於WhitelabelApprovalEndpoint這個類,主要用於單點登陸是否受權進入用的,默認會有有個白色的受權頁面出現讓客戶選擇是否受權登陸,看下源碼:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.springframework.security.oauth2.provider.endpoint;

import java.util.Iterator;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.servlet.ModelAndView;

@FrameworkEndpoint
@SessionAttributes({"authorizationRequest"})
public class WhitelabelApprovalEndpoint {
    private static String CSRF = "<input type='hidden' name='${_csrf.parameterName}' value='${_csrf.token}' />";
    private static String DENIAL = "<form id='denialForm' name='denialForm' action='${path}/oauth/authorize' method='post'><input name='user_oauth_approval' value='false' type='hidden'/>%csrf%<label><input name='deny' value='Deny' type='submit'/></label></form>";
    private static String TEMPLATE = "<html><body><h1>OAuth Approval</h1><p>Do you authorize '${authorizationRequest.clientId}' to access your protected resources?</p><form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post'><input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input name='authorize' value='Authorize' type='submit'/></label></form>%denial%</body></html>";
    private static String SCOPE = "<li><div class='form-group'>%scope%: <input type='radio' name='%key%' value='true'%approved%>Approve</input> <input type='radio' name='%key%' value='false'%denied%>Deny</input></div></li>";

    public WhitelabelApprovalEndpoint() {
    }

    @RequestMapping({"/oauth/confirm_access"})
    public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
        String template = this.createTemplate(model, request);
        if (request.getAttribute("_csrf") != null) {
            model.put("_csrf", request.getAttribute("_csrf"));
        }

        return new ModelAndView(new SpelView(template), model);
    }

    protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
        String template = TEMPLATE;
        if (!model.containsKey("scopes") && request.getAttribute("scopes") == null) {
            template = template.replace("%scopes%", "").replace("%denial%", DENIAL);
        } else {
            template = template.replace("%scopes%", this.createScopes(model, request)).replace("%denial%", "");
        }

        if (!model.containsKey("_csrf") && request.getAttribute("_csrf") == null) {
            template = template.replace("%csrf%", "");
        } else {
            template = template.replace("%csrf%", CSRF);
        }

        return template;
    }

    private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
        StringBuilder builder = new StringBuilder("<ul>");
        Map<String, String> scopes = (Map)((Map)(model.containsKey("scopes") ? model.get("scopes") : request.getAttribute("scopes")));
        Iterator var5 = scopes.keySet().iterator();

        while(var5.hasNext()) {
            String scope = (String)var5.next();
            String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
            String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
            String value = SCOPE.replace("%scope%", scope).replace("%key%", scope).replace("%approved%", approved).replace("%denied%", denied);
            builder.append(value);
        }

        builder.append("</ul>");
        return builder.toString();
    }
}

TEMPLATE這裏面的網頁代碼字符串就是相關的受權頁面,顯示是否受權或者拒絕受權的頁面,當咱們選擇受權後咱們會跳轉到另外一個服務器的頁面.

若是咱們不想讓它顯示出來受權頁面(由於這樣會影響用戶體驗),咱們能夠在原始的文檔中寫<scripts>代碼讓它自動提交,以下所示:

private static String TEMPLATE = "<html><body><div style='display:none;'><h1>OAuth Approval</h1>"
            + "<p>Do you authorize '${authorizationRequest.clientId}' to access your protected resources?</p>"
           + "<form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post'><input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input name='authorize' value='Authorize' type='submit'/></label></form>"
           + "%denial%</div>
           <script>document.getElementById('confirmationForm').submit()</script></body></html>";

咱們用<div style='display:none;'>來表示這個頁面是空白的,而後咱們加上<script>的編寫來自動提交,造成一個空白頁面一閃而過的效果(不須要再手動點擊受權)

遇到的坑總結:

時常配置好後報出以下錯誤:

***************************
APPLICATION FAILED TO START
***************************

Description:

Method springSecurityFilterChain in org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration required a single bean, but 4 were found:
    - remoteTokenServices: defined by method 'remoteTokenServices' in class path resource [org/springframework/boot/autoconfigure/security/oauth2/resource/ResourceServerTokenServicesConfiguration$RemoteTokenServicesConfiguration$TokenInfoServicesConfiguration.class]
    - consumerTokenServices: defined by method 'consumerTokenServices' in class path resource [org/springframework/security/oauth2/config/annotation/web/configuration/AuthorizationServerEndpointsConfiguration.class]
    - defaultAuthorizationServerTokenServices: defined by method 'defaultAuthorizationServerTokenServices' in class path resource [org/springframework/security/oauth2/config/annotation/web/configuration/AuthorizationServerEndpointsConfiguration.class]
    - defaultTokenServices: defined by method 'defaultTokenServices' in class path resource [urity/demo/oauth2/AuthorizationServerConfiguration.class]


Action:

Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

這個是security找不到使用哪一個類報錯的問題,這裏是資源類不明,因此咱們在資源的相關配置上加上@Primary註解來解決:

/**
     * 建立一個默認的資源服務token
     *
     * @return
     */
    @Bean
    @Primary
    public ResourceServerTokenServices defaultTokenServices() {
        final DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenEnhancer(accessTokenConverter());
        defaultTokenServices.setTokenStore(jwtStore());
        return defaultTokenServices;
    }

項目git地址

(喜歡記得點星支持哦,謝謝!)

https://github.com/fengcharly/springOauth-sso-CAS

相關文章
相關標籤/搜索