相关文章
1、spring boot oauth2单点登录(一)-实现例子
2、spring boot oauth2单点登录(二)-客户端信息存储
3、spring boot oauth2单点登录(三)-token存储方式
源码地址
后端:https://gitee.com/fengchangxin/sso
前端:https://gitee.com/fengchangxin/sso-page
前后端分离单点登录,后端返回json数据,不涉及页面渲染。最近在学习如何用spring oauth2来做单点登录时,发现网上的例子基本上都是不分离的,或者只讲原理而没有代码。通过对spring oauth2的debug跟踪,大概了解它的执行流程,然后才做出这个例子,但由于前端了解不多,以及对spring oauth2源码了解不够深,与标准的oauth2流程有些差异,如果大家有更好的想法可以留言,但不一定回。下面进入正题:
一、环境准备
此篇文章涉及的项目基于windows系统
后端:jdk1.8、三个spring boot服务(授权中心服务:auth、客户端服务1:client1、客户端服务2:client2)
前端:node.js、vue.js,三个Vue项目(授权中心前端:auth、客户端1前端:client1、客户端2前端:client2)
三个域名:oauth.com(授权中心)、client1.com(客户端1)、client2.com(客户端2)
准备好nginx
二、后端项目
1、授权中心服务:auth
1.1 自定义未登录、登录成功、登录失败的返回处理
未登录处理
在这里做了两个逻辑处理,根据参数isRedirect是否是true,如果是true则重定向到授权中心auth的前端登录页,若为空或false,则返回授权中心的后端授权接口,并带上isRedirect=true,定义Result对象的code为800则为未登录。
@Component("unauthorizedEntryPoint")
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
Map<String, String[]> paramMap = request.getParameterMap();
StringBuilder param = new StringBuilder();
paramMap.forEach((k, v) -> {
param.append("&").append(k).append("=").append(v[0]);
});
param.deleteCharAt(0);
String isRedirectValue = request.getParameter("isRedirect");
if (!StringUtils.isEmpty(isRedirectValue) && Boolean.valueOf(isRedirectValue)) {
response.sendRedirect("http://oauth.com/authPage/login?"+param.toString());
return;
}
String authUrl = "http://oauth.com/auth/oauth/authorize?"+param.toString()+"&isRedirect=true";
Result result = new Result();
result.setCode(800);
result.setData(authUrl);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
PrintWriter writer = response.getWriter();
ObjectMapper mapper = new ObjectMapper();
writer.print(mapper.writeValueAsString(result));
writer.flush();
writer.close();
}
}
登录成功处理
这比较简单,就返回一个json对象,Result对象的code为0则是成功,其他失败。
@Component("successAuthentication")
public class SuccessAuthentication extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
PrintWriter writer = response.getWriter();
Result result = new Result();
result.setCode(0);
result.setMsg("成功");
ObjectMapper mapper = new ObjectMapper();
writer.println(mapper.writeValueAsString(result));
writer.flush();
writer.close();
}
}
登录失败处理
和登录成功差不多的处理
@Component("failureAuthentication")
public class FailureAuthentication extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
PrintWriter writer = response.getWriter();
Result result = new Result();
result.setCode(1000);
result.setMsg("登录失败");
ObjectMapper mapper = new ObjectMapper();
writer.println(mapper.writeValueAsString(result));
writer.flush();
writer.close();
}
}
1.2 资源配置和security配置
资源配置
定义了两个客户端,可以通过数据库方式来加载,至于如何实现网上有教程,我这里图方便用硬编码两个客户端信息,这里有个问题需要注意,就是客户端的回调地址只能写/login,这是因为@EnableOAuth2Sso的客户端默认传的授权回调地址就是login,这应该可以修改,但我不知道如何操作。
@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients()
.tokenKeyAccess("isAuthenticated()");
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(inMemoryClientDetailsService());
}
@Bean
public ClientDetailsService inMemoryClientDetailsService() throws Exception {
return new InMemoryClientDetailsServiceBuilder()
// client oa application
.withClient("client1")
.secret(passwordEncoder.encode("client1_secret"))
.scopes("all")
.authorizedGrantTypes("authorization_code", "refresh_token")
.redirectUris("http://client1.com/client1/login")
.accessTokenValiditySeconds(7200)
.autoApprove(true)
.and()
// client crm application
.withClient("client2")
.secret(passwordEncoder.encode("client2_secret"))
.scopes("all")
.authorizedGrantTypes("authorization_code", "refresh_token")
.redirectUris("http://client2.com/client2/login")
.accessTokenValiditySeconds(7200)
.autoApprove(true)
.and()
.build();
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.accessTokenConverter(jwtAccessTokenConverter())
.tokenStore(jwtTokenStore());
}
@Bean
public JwtTokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setSigningKey("123456");
return jwtAccessTokenConverter;
}
}
security 配置
这里把上面自定义的未登录、登录成功和失败的处理加载进来,同时设了两个用户账号admin和user1,密码都是123456,用于页面登录。
@EnableWebSecurity
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private SuccessAuthentication successAuthentication;
@Autowired
private FailureAuthentication failureAuthentication;
@Autowired
private UnauthorizedEntryPoint unauthorizedEntryPoint;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceBean()).passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// http.formLogin()
// .loginPage("/login")
// .and()
// .authorizeRequests()
// .antMatchers("/login").permitAll()
// .anyRequest()
// .authenticated()
// .and().csrf().disable().cors();
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedEntryPoint)
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin().successHandler(successAuthentication).failureHandler(failureAuthentication);
}
@Bean
@Override
public UserDetailsService userDetailsServiceBean() {
Collection<UserDetails> users = buildUsers();
return new InMemoryUserDetailsManager(users);
}
private Collection<UserDetails> buildUsers() {
String password = passwordEncoder().encode("123456");
List<UserDetails> users = new ArrayList<>();
UserDetails user_admin = User.withUsername("admin").password(password).authorities("ADMIN", "USER").build();
UserDetails user_user1 = User.withUsername("user1").password(password).authorities("USER").build();
users.add(user_admin);
users.add(user_user1);
return users;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
1.3 设置允许跨域
当前端调用客户端接口时,如果未登录客户端就会重定向到授权中心服务auth请求授权,这就涉及到跨域了,如果不加这个配置,sso流程无法走通。在这里设置了所有域都可以访问,这是不安全的,可以结合动态配置中心或者数据库来动态加载允许访问的域名。
@Order(Ordered.HIGHEST_PRECEDENCE)
@Configuration
public class CORSFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
HttpServletRequest request = (HttpServletRequest) req;
//允许所有的域访问,可以设置只允许自己的域访问
response.setHeader("Access-Control-Allow-Origin", "*");
//允许所有方式的请求
response.setHeader("Access-Control-Allow-Methods", "*");
//头信息缓存有效时长(如果不设 Chromium 同时规定了一个默认值 5 秒),没有缓存将已OPTIONS进行预请求
response.setHeader("Access-Control-Max-Age", "3600");
//允许的头信息
response.setHeader("Access-Control-Allow-Headers", "Content-Type,XFILENAME,XFILECATEGORY,XFILESIZE,x-requested-with,Authorization");
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
} else {
chain.doFilter(req, res);
}
}
}
1.4 yml配置
server:
port: 8080
servlet:
context-path: /auth
session:
cookie:
name: SSO-SESSION
2、客户端服务
因为两个客户端的是几乎相同的,所以这里只展示client1的,详细代码可以到文章开头那里下载。
2.1 security配置
使用@EnableOAuth2Sso注解,使用单点登录,所有的接口都需要登录之后才可访问。
@EnableOAuth2Sso
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout()
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
2.2 yml配置
这里配置了oauth2流程的必要配置。
server:
port: 8081
servlet:
context-path: /client1
security:
oauth2:
client:
client-id: client1
client-secret: client1_secret
access-token-uri: http://oauth.com/auth/oauth/token
user-authorization-uri: http://oauth.com/auth/oauth/authorize
resource:
jwt:
key-uri: http://oauth.com/auth/oauth/token_key
2.3 定义两个接口
定义了一个测试接口/test,至于第二个接口是回调接口,当客户端授权成功后最后一步调用,这里重定向返回到对应客户端的前端地址。
@RestController
public class Controller {
@GetMapping("/test")
public Result test() {
System.out.println("11111");
Result result = new Result();
result.setCode(0);
result.setData("hello client1");
return result;
}
@GetMapping("/")
public void callback(HttpServletResponse response) throws IOException {
response.sendRedirect("http://client1.com/client1Page/home");
}
}
三、前端项目
1、授权中心前端:auth
授权中心的前端页面写了一个简单的登录页,当点击登录按钮时调用login()方法,方法调用授权中心后端接口,如果返回的json的code为0,则登录成功,然后跳转到授权中心后端授权接口,这里要用window.location.href跳转,而不能用js调用,否则无法跳转到客户端。
<template>
<div>
<p>账号:</p>
<input type="text" v-model="loginForm.username">
<p>密码:</p>
<input type="password" v-model="loginForm.password">
<p></p>
<button v-on:click="login">登录</button>
</div>
</template>
<script>
import {postRequest} from "../utils/api";
export default {
name: 'Login',
data() {
return {
loginForm: {
username: '',
password: ''
}
}
},
methods: {
login() {
postRequest('/auth/login', this.loginForm).then(resp => {
if (resp.data.code === 0) {
var pageUrl = window.location.href
var param = pageUrl.split('?')[1]
window.location.href = '/auth/oauth/authorize?'+param
} else {
console.log('登录失败:'+resp.data.msg)
}
})
}
}
}
</script>
<style scoped>
</style>
2、客户端client1前端
客户端client2的代码基本一样,在test()方法中调用客户端后端接口,如果返回的code为0则显示数据,如果返回800,是未登录然后跳转到授权中心的授权接口,这里的800返回是在授权中心后端的自定义未登录 处理UnauthorizedEntryPoint返回的,与标准oauth2流程相比,这里多了一次跳转到授权接口,在UnauthorizedEntryPoint然后重定向到授权中心的登录页。
<template>
<div>
<button v-on:click="test">显示</button>
<p>client1显示结果:{{msg}}</p>
</div>
</template>
<script>
import {getRequest} from "../utils/api";
export default {
name: 'Home',
data () {
return {
msg: ''
}
},
methods: {
test() {
getRequest('/client1/test').then(resp=>{
if (resp.data.code === 0) {
this.msg = resp.data.data
}else if (resp.data.code === 800) {
window.location.href = resp.data.data
} else {
console.log('失败:'+resp.data)
}
})
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
代码已经准备好,一些细节的代码需要从码云下载了解,在文章就不展示了,接下来就是测试了。
四、测试
1、环境配置准备
1.1 配置hosts
在hosts中添加下面三个域名配置,如果都用localhost来测试的话,测试无法知道单点登录流程是否正常,因为三个项目的域名相同的话cookie可能会造成干扰。
127.0.0.1 oauth.com
127.0.0.1 client1.com
127.0.0.1 client2.com
1.2 nginx配置
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
server {
listen 80;
server_name oauth.com;
location /auth/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:8080/auth/;
}
location ^~ /authPage {
try_files $uri $uri/ /authPage/index.html;
}
}
server {
listen 80;
server_name client1.com;
location /client1/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:8081/client1/;
}
location ^~ /client1Page {
try_files $uri $uri/ /client1Page/index.html;
}
}
server {
listen 80;
server_name client2.com;
location /client2/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:8082/client2/;
}
location ^~ /client2Page {
try_files $uri $uri/ /client2Page/index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
注意:配置后端接口时要加上下面两句,不然后端重定向时域名会变成localhost,导致流程失败。
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1.3 前端打包部署
如何在nginx下打包部署Vue项目,可以看我的这篇文章。
1.4 启动后端服务
依次启动nginx、auth、client1、client2后端服务。
2、测试
在浏览器输入http://client1.com/client1Page/home,访问客户端1的前端地址,点击显示按钮会跳转到授权中心的登录页,输入账号admin,密码123456,登录成功后会重定向到客户端1的页面,此页面地址就是client1后端的的callback接口里设置的重定向地址,然后再点击按钮下面会显示client1后端接口返回的数据。
然后浏览器再开一个标签页,输入http://client2.com/client2Page/home,访问客户端2的前端地址,点击显示按钮然后请求授权中心授权,然后不需要登录就授权成功并重定向到客户端2的页面,此页面地址就是client2后端的callback接口里设置的重定向地址,这里设置了相同的页面,所以不要错误认为没有登录成功,然后点击显示按钮下面会显示client2后端返回的数据。
五、流程解析
1、UML图
1.1 client1流程
此流程与标准的oauth2流程相比,多了两次授权请求,按照正常oauth2流程,在第一次请求授权时如果未登录就重定向到登录页,但用前后端分离后,返回了授权接口在前端跳转,此时多了一次授权请求,在登录成功后又再次请求授权接口,这样做的原因是登录成功后,client2再请求时无法获取到登录成功后的SSO-SESSION这个cookie,从而导致需要再登录,我认为拿不到cookie的原因是在不同域名下请求另一个域名的接口是无法取到cookie的,所以只能在浏览器上跳转,授权中心根据isRedirect这个参数来判断是重定向到登录页还是返回json未登录。