什麼是單點登陸?
咱們看一個例子:咱們訪問taobao的時候,在點擊下淘寶主頁的天貓,咱們發現其實他是兩個域名;因此應該是不一樣的服務器。 html
而後咱們再次登陸下淘寶帳號: 前端
淘寶登陸的域名又是和淘寶/天貓首頁的不一致,三個不一樣的域名錶明瞭3個不一樣服務器。 java
而後咱們輸入帳號登陸淘寶,而後刷新下天貓,發現天貓也登錄了。web
單點登陸:咱們登陸一臺服務器(系統)以後,同時也登陸了另外一臺服務(系統)。spring
基於JWT實現SSO登陸。數據庫
咱們寫的代碼是基於spring security,spring security oauth技術棧實現,上面JWT實現SSO的流程也是基於spring security,spring security oauth技術棧實現的描述的。可是若是你的應用A和應用B不是基於Spring Security來作的,甚至不是用java來寫的,上面的流程也是試用的。只須要應用A和應用B是基於http的,而後基於http完成上面流程你就能夠按照上面模式實現sso登陸。固然認證服務器和資源服務是須要咱們本身搭建的,可是搭建這些的話使用Spring Security是很容易實現的。express
咱們怎麼不在原來的代碼上去寫了,有2個緣由: 緣由一:原來代碼結構並不適合我當前sso登陸場景。原來代碼結構是按照瀏覽器安全session的安全 咱們須要怎樣控制?基於App的安全令牌token的方式咱們如何控制?,是按照上面2種方式來區分開的。 可是在sso的模式下,認證服務器是一個特殊存在,他是由基於瀏覽器的處理,各類跳轉,session的處理,另一部分,他也會發令牌。基於瀏覽器,基於session,同時也要發令牌。他會混合咱們以前講解的全部東西。 緣由二:在原來基礎上,咱們修改代碼是能夠實現sso的,可是代碼的複雜度會加大。 咱們單獨使用工程搭建,實現功能不會操做100行代碼,能夠把以前全部的代碼邏輯實現所有串起來。 後端
其中
oss-server:認證服務器
oss-clientA:應用服務A
oss-clientB:應用服務B瀏覽器
引入2個starter項目:web(會引入spring mvc 那套東西)、security(spring-security相關依賴)、咱們要基於oauth2發令牌,基於jwt生成令牌。安全
<dependencies> <!--spring security starter 依賴--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!--spring mvc starter web依賴--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--spring-security-oauth2依賴--> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> </dependency> <!--spring-security-jwt依賴--> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-jwt</artifactId> </dependency> </dependencies>
而後建立啓動類
a.配置client端,配置TokenConvert和TokeStore對應的bean,而後將其配置到configure的的endpoints去. b.認證服務器的安全配置:AuthorizationServerSecurityConfigurer
@Configuration @EnableAuthorizationServer public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { /** * 配置客戶端受權: */ @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("clientA") .secret("clientAsecret") .authorizedGrantTypes("authorization_code","refresh_token") .scopes("all") .and() .withClient("clientB") .secret("clientBsecret") .authorizedGrantTypes("authorization_code","refresh_token") .scopes("all"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(jwtTokenStore()) .accessTokenConverter(jwtAccessTokenConverter()); } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { /** * isAuthenticated():spring security的受權表達式。 */ //咱們訪問認證服務器的tokenKey時候須要通過身份認證;tokenKey就是咱們在jwtAccessTokenConverter裏面寫的yxm //咱們爲何須要訪問tokenKey?咱們以前sso認證流程時候,會生成一個jwt返回回去,而這個jwt是:須要祕鑰去簽名,咱們的場景裏面是:yxm //當應用A獲取到JWT時候,他解析裏面的東西 他就要去驗簽名 他要驗簽名 那麼這個應用A就須要知道簽名用的祕鑰是什麼?咱們後面讓應用A去訪問 //tokenKey時候,就會受權才能獲取。 security.tokenKeyAccess("isAuthenticated()"); } @Bean public TokenStore jwtTokenStore(){ return new JwtTokenStore(jwtAccessTokenConverter()); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter(){ JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("yxm");//咱們將其寫死 return converter; } }
server: port: 9999 context-path: /server security: user: password: 123456
後面咱們從應用A跳轉到認證服務器時候輸入的密碼是要輸入:123456
啓動類配置
@SpringBootApplication @RestController @EnableOAuth2Sso //應用端SSO登陸須要添加此註解 public class SsoClientApplication { @GetMapping("/user") public Object user(Authentication user){ return user; } public static void main(String[] args) { SpringApplication.run(SsoClientApplication.class,args); } }
配置文件配置
由於咱們做爲客戶端去訪問認證服務器。
security: oauth2: client: clientId: clientA clientSecret: clientAsecret #配置應用A須要認證時候,認證服務器地址,應用跳轉進行認 證的url user-authorization-uri: http://127.0.0.1:9999/server/oauth/authorize #配置應用A須要認證完成以後,認證服務器返回的獲取token的地址 access-token-uri: http://127.0.0.1:9999/server/oauth/token resource: jwt: # 用戶獲取到jwt後須要使用祕鑰解析jwt時候的祕鑰生成地址 key-uri: http://127.0.0.1:9999/server/oauth/token_key server: port: 7777 context-path: /clientA
頁面配置
頁面配置跳轉到clientB
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>SSO ClientA</title> </head> <body> <h1>SSO Demo ClientA</h1> <a href="http://127.0.0.1:8060/clientB/index.html"> 訪問ClientB</a> </body> </html>
啓動類配置
@SpringBootApplication @RestController @EnableOAuth2Sso //應用端SSO登陸須要添加此註解 public class SsoClientApplication { @GetMapping("/user") public Object user(Authentication user){ return user; } public static void main(String[] args) { SpringApplication.run(SsoClientApplication.class,args); } }
配置文件配置
由於咱們做爲客戶端去訪問認證服務器。
security: oauth2: client: clientId: clientB clientSecret: clientBsecret #配置應用A須要認證時候,認證服務器地址,應用跳轉進行認證的url user-authorization-uri: http://127.0.0.1:9999/server/oauth/authorize #配置應用A須要認證完成以後,認證服務器返回的獲取token的地址 access-token-uri: http://127.0.0.1:9999/server/oauth/token resource: jwt: # 用戶獲取到jwt後須要使用祕鑰解析jwt時候的祕鑰生成地址 key-uri: http://127.0.0.1:9999/server/oauth/token_key server: port: 7777 context-path: /clientB
頁面配置
頁面配置跳轉到clientB
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>SSO ClientB</title> </head> <body> <h1>SSO Demo ClientB</h1> <a href="http://127.0.0.1:8060/clientA/index.html">訪問ClientA</a> </body> </html>
咱們訪問服務A:http://127.0.0.1:7777/clientA/index.html
服務A會
clientA沒有作個性化配置,實際上使用了spring security的默認安全配置。全部url都會受到保護,訪問全部url都須要身份認證。那麼咱們一訪問index.html頁面就會把咱們的請求轉到認證服務器上作認證。去請求受權。
clientA沒有作個性化配置,實際上使用了spring security的默認安全配置。全部url都會受到保護,訪問全部url都須要身份認證。那麼咱們一訪問index.html頁面就會把咱們的請求轉到認證服務器上作認證。去請求受權。
http://127.0.0.1:9999/server/oauth/authorize?client_id=clientA&redirect_uri=http://127.0.0.1:7777/clientA/login&response_type=code&state=OGI2Q7
咱們上面雖然跳轉到到了認證服務器上:http://127.0.0.1:9999/server/oauth/authorize可是認證服務器也是不知道我是誰,他須要咱們用戶作一個登陸。由於咱們以前也是沒作什麼配置,因此他會按照spring security默認安全配置,彈出一個http basic的認證塊,讓我輸入用戶名/密碼。
登陸以後跳轉到受權頁面:提示登陸用戶,你是否受權clientA來訪問你受保護資源。
咱們點擊容許,就會跳轉到:http://127.0.0.1:7777/clientA/index.html
這個時候咱們已經作了身份認證了,若是沒有作身份認證咱們是看不到這個頁面的。
而後咱們訪問下(查看用戶):http://127.0.0.1:7777/clientA/user
咱們能查看到客戶信息。
咱們點擊"訪問ClientB",這個時候至關於咱們用戶直接請求ClientB對應的請求,此時ClientB也是不識別我這個用戶,也會直接跳轉到認證服務器上的。
http://127.0.0.1:9999/server/oauth/authorize?client_id=clientB&redirect_uri=http://127.0.0.1:8888/clientB/login&response_type=code&state=vL2MJw
這個時候咱們點擊受權:咱們會直接跳轉到:http://127.0.0.1:8888/clientB/index.html
咱們發現此時咱們沒有進行登陸:由於此時認證服務器是知道個人身份的,咱們已經在應用A訪問時候已經登陸認證過了。此時提示你是否受權:clientB來訪問。
最後咱們就能夠在clientA和ClientB的index.html上隨便切換訪問了。
查看Jwt信息:
訪問應用A:http://127.0.0.1:7777/clientA/user
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODM4NjkwNjQsInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiM2RlYTIzNDEtZjA3Yi00MDBkLWFmNTctMGUyOWI2MWZmZWJiIiwiY2xpZW50X2lkIjoiY2xpZW50QSIsInNjb3BlIjpbImFsbCJdfQ.wPNWgRJR2yI9mq4t3ZZS81H3TpErmwkekQp3hiYEUjI
對應用戶信息:
訪問應用B:http://127.0.0.1:8888/clientB/user
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODM4Njk1NzksInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiY2YzY2E5ZTctYzkyYS00M2Y4LWJjY2YtYWUwNjY4YjNlZWYxIiwiY2xpZW50X2lkIjoiY2xpZW50QiIsInNjb3BlIjpbImFsbCJdfQ.nRmh-BtqpuTucG2s4iVDdqStCeKApvioA9W953F3XUU
咱們經過最後幾位發現其對應的JWT是不同的。可是對應的用戶信息實際上是同樣的。當時他們能從不一樣的jwt中解析出相同用戶信息作登陸。
上面最核心的功能實現了:只登錄一次,而後用這一次登錄信息訪問應用A和應用B
咱們如何把http的basic登陸變成表單登陸。
咱們新增:SsoSecurityConfig;修改裏面的configure方法。
@Configuration public class SsoSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { //http表單登陸的全部請求都須要受權 http.formLogin().and().authorizeRequests().anyRequest().authenticated(); } }
咱們不使用application.yml文件裏面配置的信息
security: user: password: 123456
而是使用咱們數據庫裏面的用戶名/密碼,咱們此時須要自定義;其中密碼的話,咱們設置爲Spring Security推薦的加密編碼格式。並覆蓋掉咱們:AuthenticationManager的配置,告訴他用我本身的UserDetailsService。
和加密器用做身份認證。
@Configuration public class SsoSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService ssoUserDetailsService; @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(ssoUserDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { //http表單登陸的全部請求都須要受權 http.formLogin().and().authorizeRequests().anyRequest().authenticated(); } }
UserDetailsService
@Component public class SsoUserDetailsService implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // public User(String username, String password, Collection<? extends GrantedAuthority> authorities) return new User(username,passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER")); } }
重啓服務在此訪問:
http://127.0.0.1:8888/clientB/index.html
此時用戶訪問應用A時候,會跳到認證服務器進行認證。
而後跳轉到:
http://127.0.0.1:9999/server/oauth/authorize?client_id=clientB&redirect_uri=http://127.0.0.1:8888/clientB/login&response_type=code&state=8WbHSO
點擊受權,而後跳轉到咱們受權的頁面:
再點擊"訪問ClientA",也會跳轉到認證服務器的受權頁面
而後點擊受權進入:ClientA頁面。
最後能夠在ClientA和clientB之間輪流切換。
咱們如今須要登陸以後不受權,直接跳轉到對應的頁面,受權哪一個頁面咱們沒辦法跳過去的,由於受權碼oauth2協議決定了。
咱們的思路是:找到受權對應表單,而後找到具體是從哪裏生成出來的,而後改造生成表單的頁面,一進頁面就自動的提交掉。用戶不須要直接去點擊。
咱們跟蹤代碼了,發現是:WhitelabelApprovalEndpoint
@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(); } }
其@FrameworkEndpoint註解與@RestController相似。其下可使用註解: @RequestMapping({"/oauth/confirm_access"})訪問。
咱們新建一個與上面同名字的類:WhitelabelApprovalEndpoint使用@RestController來註解,spring在處理的時候優先會處理執行@RestController標註的類。類名和@RestController與WhitelabelApprovalEndpoint不同,其餘照搬。
@RestController @SessionAttributes({"authorizationRequest"}) public class SsoApprovalEndpoint { 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 SsoApprovalEndpoint() { } @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 SsoSpelView(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(); } }
因爲裏面會使用一個實現View接口的類,因此咱們
裏面的SpelView類不是共有的:public 因此咱們不能複用,須要自定義一個實現View接口歐的類。
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 = SsoSpelView.this.parser.parseExpression(name); Object value = expression.getValue(SsoSpelView.this.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(model); String path = ServletUriComponentsBuilder.fromContextPath(request).build().getPath(); map.put("path", path == null ? "" : path); this.context.setRootObject(map); String maskedTemplate = this.template.replace("${", this.prefix); PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper(this.prefix, "}"); String result = helper.replacePlaceholders(maskedTemplate, this.resolver); result = result.replace(this.prefix, "${"); response.setContentType(this.getContentType()); response.getWriter().append(result); } }
咱們看到前端生成的頁面就是屬性TEMPLATE:
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>";
咱們修改這個屬性:添加div設置裏面的display:none 而後作一個表單的提交。
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>";
重啓服務,而後咱們訪問clientA的首頁
咱們點擊用戶名/密碼登陸,而後頁面一閃就到達頁面:
而後咱們點擊"訪問ClientB",一閃跳到ClientA