Spring Security-30-单点登录案例

上文中,将默认的token替换成了 jwt ,本文将基于 jwt 实现一个单点登录的案例,流程图如下:
image

大概流程就是,应用A去请求登录,然后跳转到统一的认证服务器上去做认证,之后返回给应用A一个token, 这时候B应用去请求登录,认证服务器会发现应用A已经登录过了,那就不需要再认证了,直接返回令牌给应用B

关于单点登录的流程这里不做详细的描述了,下面看下如何实现

准备环境

新建三个项目,不在之前的项目上做修改了

  • sso-server:认证服务器
  • sso-client-a:应用A
  • sso-client-b:应用B

总的父工程依赖和之前的项目,一样,其他三个工程的依赖如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- spring boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

<!-- spring-security-oauth2 -->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>

<!-- jwt -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>

可以直接从我的github拉下来用,传送门

认证服务器

环境准备完成之后,实现一下认证服务器 server 项目中,新建配置类 SsoAuthorizationServerConfig ,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Configuration
@EnableAuthorizationServer
public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

private static final String DEFAULT_SIGN_KEY = "default-sign-key";

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(jwtTokenStore())
.accessTokenConverter(jwtAccessTokenConverter());
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("testAppId1")
.secret("testSecert1")
.authorizedGrantTypes("authorization_code", "refresh_token", "password")
.redirectUris("http://127.0.0.1:8080/client-a/login")
.scopes("all")
.and()
.withClient("testAppId2")
.secret("testSecert2")
.authorizedGrantTypes("authorization_code", "refresh_token", "password")
.redirectUris("http://127.0.0.1:8081/client-b/login")
.scopes("all");
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 表是在访问认证服务器的 tokenKey(就是 DEFAULT_SIGN_KEY ) 的时候,需要经过身份认证
security.tokenKeyAccess("isAuthenticated()");
}

@Bean
public TokenStore jwtTokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
// token生成中的一些处理
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(DEFAULT_SIGN_KEY);
return converter;
}

}

这个跟我们之前的认证服务器的配置是一样的,这里的client的信息是基于内存配置的,之前配置的是基于数据库的, 这里重点是第三个configure() 方法, 之前我们只重写了两个

在认证服务器认证完成之后,会给应用返回一个 JWT 的token, 应用需要解析的话,就需要用到jwt的 SigningKey ,这里这个方法就表示在应用服务器从认证服务器获取 SigningKey 的时候,应用必须是已登录的状态

其他配置跟之前一样不多做解释了

application.yml 的配置如下:

1
2
3
4
5
6
server:
port: 9999
context-path: /server
security:
user:
password: 123456

认证服务器的配置就完成了

应用服务器

应用服务器很简单,首先,启动类需要加一个注解,如下:

1
2
3
4
5
6
7
8
9
@EnableOAuth2Sso
@SpringBootApplication
public class App {

public static void main(String[] args) {
SpringApplication.run(App.class, args);
}

}

然后给一个测试用的controller,如下:

1
2
3
4
5
6
7
8
9
10
@RestController
@RequestMapping("/user")
public class UserController {

@GetMapping("/me")
public Authentication me(Authentication authentication){
return authentication;
}

}

然后重点在配置文件中, application.yml 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
security:
oauth2:
client:
client-id: testAppId1
client-secret: testSecert1
user-authorization-uri: http://127.0.0.1:9999/server/oauth/authorize
access-token-uri: http://127.0.0.1:9999/server/oauth/token
resource:
jwt:
key-uri: http://127.0.0.1:9999/server/oauth/token_key
server:
port: 8080
context-path: /client-a

主要就是一个认证的请求,获取token的请求,然后上面那个获取 jwt 的 SigningKey 的请求路径的配置

然后 client-b 的配置也是相同的

测试

三个项目都启动好之后,随便访问一个客户端的 /user/me 接口,这时候会跳转到 127.0.0.1:9999 的认证界面上,由于没做任何配置,所以这里是一个默认的 basic 认证, 然后输入用户名密码,会弹出一个授权的界面,点击同意之后会跳转到用户信息的界面

之后再访问另一个客户端的 /user/me 接口,这次就不需要登录了,直接是授权的界面,点击同意,返回用户的信息

配置自定义登录页面

上面由于没有做任何配置,所以默认的就是 basic 的认证方式,那如何配置登录页, 其实跟之前是一样的,新建一个配置的类 MyWebSecurityConfigurerAdapter 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.and()
// 所有的请求都必须授权后才能访问
.authorizeRequests()
.anyRequest()
.authenticated();
}
}

然后这里是使用了一个默认的登录页,然后这个里面还可以配置各种,比如 userDetailService 等等,跟之前的是一样的

优化授权

上面的示例中, 每次应用切换都需要做一次授权的操作,如下: image

每次切换都要授权一次,很烦,所以想办法把这一步跳过,让他自己授权

跟踪源码, 有一个 WhitelabelApprovalEndpoint ,是一个返回授权页面的controller, 我们只要覆盖掉这个controller就可以了, 新建类 MyWhitelabelApprovalEndpoint 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@RestController
@SessionAttributes("authorizationRequest")
public class MyWhitelabelApprovalEndpoint {

@RequestMapping("/oauth/confirm_access")
public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
final String approvalContent = createTemplate(model, request);
if (request.getAttribute("_csrf") != null) {
model.put("_csrf", request.getAttribute("_csrf"));
}
View approvalView = new View() {
@Override
public String getContentType() {
return "text/html";
}

@Override
public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType(getContentType());
response.getWriter().append(approvalContent);
}
};
return new ModelAndView(approvalView, model);
}

protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
String clientId = authorizationRequest.getClientId();

StringBuilder builder = new StringBuilder();
// 让body不显示
builder.append("<html><body style='display:none;'><h1>OAuth Approval</h1>");
builder.append("<p>Do you authorize \"").append(HtmlUtils.htmlEscape(clientId));
builder.append("\" to access your protected resources?</p>");
builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");

String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
if (requestPath == null) {
requestPath = "";
}

builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");

String csrfTemplate = null;
CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
if (csrfToken != null) {
csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
"\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
}
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}

String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"Authorize\" type=\"submit\"/></label></form>";

if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
builder.append(createScopes(model, request));
builder.append(authorizeInputTemplate);
} else {
builder.append(authorizeInputTemplate);
builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
builder.append(requestPath).append("/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
builder.append("<label><input name=\"deny\" value=\"Deny\" type=\"submit\"/></label></form>");
}

// 添加自动提交操作
builder.append("<script>document.getElementById('confirmationForm').submit()</script>");
builder.append("</body></html>");

return builder.toString();
}

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" : "";
scope = HtmlUtils.htmlEscape(scope);

builder.append("<li><div class=\"form-group\">");
builder.append(scope).append(": <input type=\"radio\" name=\"");
builder.append(scope).append("\" value=\"true\"").append(approved).append(">Approve</input> ");
builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
builder.append(denied).append(">Deny</input></div></li>");
}
builder.append("</ul>");
return builder.toString();
}
}

就是直接把他的代码复制过来,然后加了一个自动提交的script脚本

本文代码传送门