Spring Security構建Rest服務-1300-Spring Security OAuth開發APP認證框架之JWT實現單點登陸

基於JWT實現SSOcss

 在淘寶( https://www.taobao.com )上點擊登陸,已經跳到了 https://login.taobao.com,這是又一個服務器。只要在淘寶登陸了,就能直接訪問天貓(https://www.tmall.com)了,這就是單點登陸了。html

淘寶、天貓都是一家的公司,因此呢但願用戶在訪問淘寶時若是在淘寶上作了登陸,當在訪問或者從淘寶跳轉到天貓時,直接就處於登陸狀態而不用再次登陸,用戶體驗大大的好。git

結合OAuth協議,相比就是以下的流程圖,應用A就至關於淘寶,應用B就至關於天貓,【認證服務器】就是淘寶天貓的 登陸服務器。咱們想要實現的效果就是:github

應用A上,若是用戶訪問了須要登陸的服務,引導用戶到認證服務器上作登陸,登陸後返回要訪問的服務,若是此時再訪問應用B,在應用B也處於登陸狀態,這樣當訪問應用B上受保護的服務時,就能夠不用再登陸了,這就是sso。spring

 

1,當在應用A上訪問須要登陸才能訪問的服務時,會引導用戶到認證服務器後端

2,用戶在認證服務器上作認證並受權服務器

3,認證成功並受權後,認證服務器返回受權碼給應用Asession

4,應用A帶着受權碼請求令牌app

5,認證服務器返回JWT前後端分離

6,應用A解析JWT,用用戶信息構建Authentication放在SecurityContext,作登陸

7,此時訪問應用B ,還是未受權的狀態

8,應用B請求認證服務器受權

9,認證服務器此時已經知道當前用戶是誰的,要求用戶去受權能夠用登陸信息去訪問應用B

10,發給應用B 一個新的JWT,和應用A獲得的JWT字符串是不同的,可是解析出來的用戶信息是同樣的

11,而後用用戶信息構建Authentication放在SecurityContext,完成在應用B的登陸

最終的效果就是,用戶在認證服務器上只作了一次登陸,應用A和應用B分別使用兩個JWT解析出用戶信息,構建Authentication,放在SecurityContext,都作了登陸,應用A、B的session裏都有了用戶信息,用戶既能夠訪問應用A,也能夠訪問應用B,用的身份是同樣的。

12,若是是先後端分離的,配置成資源服務器,拿着JWT去訪問你的服務。

 

具體實現

初步項目結構:

1,配置認證服務器sso-server:

AuthorizationServerConfig:這裏就先寫死了,能夠自定義成配置文件

/**
 * 認證服務器
 * ClassName: AuthorizationServerConfig 
 * @Description: TODO
 * @author lihaoyang
 * @date 2018年3月16日
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter{

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
            .withClient("imooc1")
            .secret("imoocsecrect1")
            .authorizedGrantTypes("authorization_code", "refresh_token")
            .scopes("all")
            .and()
            .withClient("imooc2")
            .secret("imoocsecrect2")
            .authorizedGrantTypes("authorization_code", "refresh_token")
            .scopes("all");
    }
    
    
    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
    
    /**
     * 給JWT加簽名
     * @Description: 給JWT加簽名
     * @param @return   
     * @return JwtAccessTokenConverter  
     * @throws
     * @author lihaoyang
     * @date 2018年3月16日
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(){
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("imooc");
        return converter;
    }

    
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());
    }
    
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        //其餘應用要訪問認證服務器的tokenKey(就是下邊jwt簽名的imooc)的時候須要通過身份認證,獲取到祕鑰才能解析jwt
        security.tokenKeyAccess("isAuthenticated()");
    }
    
    
}

application.properties:默認用戶名user,配置密碼爲123456

server.port = 9999
server.context-path = /server
security.user.password =123456  #密碼

2,client1:@EnableOAuth2Sso 註解開啓sso ,一個註解全搞定

/**
 * 
 * ClassName: SsoCient1Application 
 * @Description: TODO
 * @author lihaoyang
 * @date 2018年3月16日
 */
@SpringBootApplication
@RestController
@EnableOAuth2Sso public class SsoClient1Application {

    @GetMapping("/user")
    public Authentication  user(Authentication  user){
        return user;
    }
    
    public static void main(String[] args) {
        SpringApplication.run(SsoClient1Application.class, args);
    }
}

配置:

security.oauth2.client.clientId = imooc1
security.oauth2.client.clientSecret = imoocsecrect1
#認證地址
security.oauth2.client.user-authorization-uri = http://127.0.0.1:9999/server/oauth/authorize
#獲取token地址
security.oauth2.client.access-token-uri = http://127.0.0.1:9999/server/oauth/token
#拿認證服務器密鑰解析jwt
security.oauth2.resource.jwt.key-uri = http://127.0.0.1:9999/server/oauth/token_key

server.port = 8080
server.context-path =/client1

client2:

/**
 * 
 * ClassName: SsoCient1Application 
 * @Description: TODO
 * @author lihaoyang
 * @date 2018年3月16日
 */
@SpringBootApplication
@RestController
@EnableOAuth2Sso
public class SsoClient2Application {

    @GetMapping("/user")
    public Authentication  user(Authentication  user){
        return user;
    }
    
    public static void main(String[] args) {
        SpringApplication.run(SsoClient2Application.class, args);
    }
}

配置

security.oauth2.client.clientId = imooc2
security.oauth2.client.clientSecret = imoocsecrect2
security.oauth2.client.user-authorization-uri = http://127.0.0.1:9999/server/oauth/authorize
security.oauth2.client.access-token-uri = http://127.0.0.1:9999/server/oauth/token
security.oauth2.resource.jwt.key-uri = http://127.0.0.1:9999/server/oauth/token_key

server.port = 8060
server.context-path =/client2

頁面:

在client1和client2的resource目錄下,新建static目錄,新建index頁,做爲client1和client2之間,能夠相互跳轉的頁面

client1:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SSO Client1</title>
</head>
<body>
    <h1>SSO Demo Client1</h1>
    <a href="http://127.0.0.1:8060/client2/index.html">訪問Client2</a>
</body>
</html>

client2:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SSO Client2</title>
</head>
<body>
    <h1>SSO Demo Client2</h1>
    <a href="http://127.0.0.1:8080/client1/index.html">訪問Client1</a>
</body>
</htm

啓動sso-server、sso-client一、 sso-client2,訪問client1 :

localhost:8080/client1,直接跳轉到了配置的認證服務器認證地址,能夠看到,url裏攜帶了一些client1配置的參數

client_id=imooc1 客戶端id,response_type=code 受權碼模式,  

提示spring security默認的登陸頁,輸入默認用戶名user,密碼123456

提示是否贊成給client1受權,這個是默認配置,後續版本須要去除這一步。點擊贊成受權

訪問到client1的index頁:

點擊跳轉到client2鏈接,能夠看到直接跳轉到了認證服務器,提示是否贊成給client2受權,此時 redirect_uri=http://127.0.0.1:8060/client2/login ,是client2

贊成受權

 再訪問client1時,也會提示是否受權,再贊成以後,就能夠相互訪問了。

 

訪問 http://127.0.0.1:8080/client1/user 查看當前用戶信息:

{
  "authorities":[
    {
      "authority":"ROLE_USER"
    }
  ],
  "details":{
    "remoteAddress":"127.0.0.1",
    "sessionId":"318DF6369A3279AB037C2528F79A42A5",
    "tokenValue":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MjE0OTQ0ODUsInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMzlkODIxZTUtMTA5Yy00MjNlLWJlZDQtNmY5YTIwMTQ2MzQ3IiwiY2xpZW50X2lkIjoiaW1vb2MxIiwic2NvcGUiOlsiYWxsIl19.zlimgyRCvwShZBcbKGcEfsUY0RlgPRqqeDLx8zRIDoQ",
    "tokenType":"bearer",
    "decodedDetails":null
  },
  "authenticated":true,
  "userAuthentication":{
    "authorities":[
      {
        "authority":"ROLE_USER"
      }
    ],
    "details":null,
    "authenticated":true,
    "principal":"user",
    "credentials":"N/A",
    "name":"user"
  },
  "principal":"user",
  "credentials":"",
  "oauth2Request":{
    "clientId":"imooc1",
    "scope":[
      "all"
    ],
    "requestParameters":{
      "client_id":"imooc1"
    },
    "resourceIds":[

    ],
    "authorities":[

    ],
    "approved":true,
    "refresh":false,
    "redirectUri":null,
    "responseTypes":[

    ],
    "extensions":{

    },
    "grantType":null,
    "refreshTokenRequest":null
  },
  "clientOnly":false,
  "name":"user"
}

 

訪問 http://127.0.0.1:8060/client2/user 查看 client2的登陸用戶信息:

{
  "authorities":[
    {
      "authority":"ROLE_USER"
    }
  ],
  "details":{
    "remoteAddress":"127.0.0.1",
    "sessionId":"EC7AD91E31A22B5B1806B86868C0F912",
    "tokenValue":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MjE0OTQ0ODMsInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMWFkMWI5N2QtNzAwZS00MzEwLWI4MmYtNmRiZmI1NWViNjIzIiwiY2xpZW50X2lkIjoiaW1vb2MyIiwic2NvcGUiOlsiYWxsIl19.YNCaXP8lOdDa_GeOjnGsc9oIGqm1VJbEas5_g8x3m7o",
    "tokenType":"bearer",
    "decodedDetails":null
  },
  "authenticated":true,
  "userAuthentication":{
    "authorities":[
      {
        "authority":"ROLE_USER"
      }
    ],
    "details":null,
    "authenticated":true,
    "principal":"user",
    "credentials":"N/A",
    "name":"user"
  },
  "credentials":"",
  "principal":"user",
  "clientOnly":false,
  "oauth2Request":{
    "clientId":"imooc2",
    "scope":[
      "all"
    ],
    "requestParameters":{
      "client_id":"imooc2"
    },
    "resourceIds":[

    ],
    "authorities":[

    ],
    "approved":true,
    "refresh":false,
    "redirectUri":null,
    "responseTypes":[

    ],
    "extensions":{

    },
    "grantType":null,
    "refreshTokenRequest":null
  },
  "name":"user"
}

©2014 JSON.cn All right reserved. 京I

能夠看到。認證服務器給 client1和client2  返回的jwt 是不同的,可是解析出來的都是 user 用戶。說明這兩個jwt 包含的信息是同樣的。

上邊的流程還存在問題。

1,sso-server 認證服務器的登陸頁是Spring Security 默認的彈框

2,在sso-server上登陸後,當跳轉到client1的服務時,還會彈出受權頁面

3,在第一次訪問 client1 和 client2 時,也會彈出受權頁面

 

這些是不友好的,下邊開始改造。

1,配置爲表單登陸

配置ss-server   

SsoUserDetailsService :是覆蓋spring默認的登陸方式,使用自定義的 loadUserByUsername 來登陸

/**
 * 配置本身的登陸,findByUsername而不是spring默認的user
 * ClassName: SsoUserDetailsService 
 * @Description: TODO
 * @author lihaoyang
 * @date 2018年3月20日
 */
@Component
public class SsoUserDetailsService implements UserDetailsService{

    @Autowired
    private PasswordEncoder passwordEncoder;
    
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                                                                        
        return new User(username,  // 用戶名 
                        passwordEncoder.encode("123456") , //密碼    
                        AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));//權限集合
        
    }

}

SsoSecurityConfig告訴spring使用本身的登陸方式,配置密碼加密器,配置那些服務須要認證等

@Configuration
public class SsoSecurityConfig extends WebSecurityConfigurerAdapter{

    @Autowired
    private UserDetailsService userDetailsService;
    
    //密碼加密解密
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    
    /**
     * 配置登陸方式等
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.formLogin() //表單登陸
            .and()
            .authorizeRequests() //全部請求都須要認證
            .anyRequest()
            .authenticated();
    }
    
    /**
     * 告訴AuthenticationManager ,使用本身的方式登陸時 【查詢用戶】和密碼加密器
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}

此時啓動應用,登陸頁就變了,就成了想要表單登陸,若是想自定義表單請看之前的文章

2,去掉點擊受權按鈕步驟

受權是Oauth協議的一部分,不可以去掉,Spring默認的受權是一個表單,讓用戶點擊受權按鈕,想要去除這個過程,思路就是在代碼裏找到這個表單,寫一段js代碼讓表單自動提交,就不須要用戶點擊了。

實際上這段代碼是在WhitelabelApprovalEndpoint 類裏的:

紅色部分就是受權的表單,使用css讓表單隱藏,寫個js自動提交表單

/**
 * Controller for displaying the approval page for the authorization server.
 * 
 * @author Dave Syer
 */
@FrameworkEndpoint
@SessionAttributes("authorizationRequest")
public class WhitelabelApprovalEndpoint {

    @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 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%", 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 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>";

}
@FrameworkEndpoint 註解和RestController的功能相似,裏邊能夠寫@RequestMapping 來處理某個請求,
可是RestController 的優先級比@FrameworkEndpoint 高,若是有兩個@RequestMapping 的映射路徑同樣,Spring會優先執行RestController 的。
因此想要覆蓋這個類的功能,要作的就是複製一份,把@FrameworkEndpoint 換成@RestController ,而後改造。
copy一份 WhitelabelApprovalEndpoint,命名爲SsoApprovalEndpoint,將
@FrameworkEndpoint 換爲 RestController ,裏邊 用到一個類SpelView,這個類不是public的,默認別的包用不了,因此這個也須要整一份,命名爲SsoSpelView

表單部分代碼:

<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></body><script>document.getElementById('confirmationForm').submit();</script></html>

這樣有點簡單粗暴,效果就是受權頁一閃而過,能夠優化優化。

具體代碼在github:https://github.com/lhy1234/spring-security

相關文章
相關標籤/搜索