什么是单点登陆?
咱们看一个例子:咱们访问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