基於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