相關(guān)文章
1们妥、spring boot oauth2單點(diǎn)登錄(一)-實(shí)現(xiàn)例子
2、spring boot oauth2單點(diǎn)登錄(二)-客戶端信息存儲(chǔ)
3郎笆、spring boot oauth2單點(diǎn)登錄(三)-token存儲(chǔ)方式
源碼地址
后端:https://gitee.com/fengchangxin/sso
前端:https://gitee.com/fengchangxin/sso-page
前后端分離單點(diǎn)登錄薄声,后端返回json數(shù)據(jù),不涉及頁(yè)面渲染题画。最近在學(xué)習(xí)如何用spring oauth2來做單點(diǎn)登錄時(shí),發(fā)現(xiàn)網(wǎng)上的例子基本上都是不分離的德频,或者只講原理而沒有代碼苍息。通過對(duì)spring oauth2的debug跟蹤,大概了解它的執(zhí)行流程壹置,然后才做出這個(gè)例子竞思,但由于前端了解不多,以及對(duì)spring oauth2源碼了解不夠深钞护,與標(biāo)準(zhǔn)的oauth2流程有些差異盖喷,如果大家有更好的想法可以留言,但不一定回难咕。下面進(jìn)入正題:
一课梳、環(huán)境準(zhǔn)備
此篇文章涉及的項(xiàng)目基于windows系統(tǒng)
后端:jdk1.8距辆、三個(gè)spring boot服務(wù)(授權(quán)中心服務(wù):auth、客戶端服務(wù)1:client1暮刃、客戶端服務(wù)2:client2)
前端:node.js跨算、vue.js,三個(gè)Vue項(xiàng)目(授權(quán)中心前端:auth椭懊、客戶端1前端:client1诸蚕、客戶端2前端:client2)
三個(gè)域名:oauth.com(授權(quán)中心)、client1.com(客戶端1)氧猬、client2.com(客戶端2)
準(zhǔn)備好nginx
二背犯、后端項(xiàng)目
1、授權(quán)中心服務(wù):auth
1.1 自定義未登錄盅抚、登錄成功漠魏、登錄失敗的返回處理
未登錄處理
在這里做了兩個(gè)邏輯處理,根據(jù)參數(shù)isRedirect是否是true泉哈,如果是true則重定向到授權(quán)中心auth的前端登錄頁(yè)蛉幸,若為空或false,則返回授權(quán)中心的后端授權(quán)接口丛晦,并帶上isRedirect=true奕纫,定義Result對(duì)象的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();
}
}
登錄成功處理
這比較簡(jiǎn)單烫沙,就返回一個(gè)json對(duì)象匹层,Result對(duì)象的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配置
資源配置
定義了兩個(gè)客戶端升筏,可以通過數(shù)據(jù)庫(kù)方式來加載,至于如何實(shí)現(xiàn)網(wǎng)上有教程瘸爽,我這里圖方便用硬編碼兩個(gè)客戶端信息您访,這里有個(gè)問題需要注意,就是客戶端的回調(diào)地址只能寫/login剪决,這是因?yàn)锧EnableOAuth2Sso的客戶端默認(rèn)傳的授權(quán)回調(diào)地址就是login灵汪,這應(yīng)該可以修改旁振,但我不知道如何操作算柳。
@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 配置
這里把上面自定義的未登錄呼奢、登錄成功和失敗的處理加載進(jìn)來研铆,同時(shí)設(shè)了兩個(gè)用戶賬號(hào)admin和user1秋泄,密碼都是123456躬充,用于頁(yè)面登錄醋寝。
@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 設(shè)置允許跨域
當(dāng)前端調(diào)用客戶端接口時(shí)丽惶,如果未登錄客戶端就會(huì)重定向到授權(quán)中心服務(wù)auth請(qǐng)求授權(quán)譬胎,這就涉及到跨域了差牛,如果不加這個(gè)配置命锄,sso流程無法走通。在這里設(shè)置了所有域都可以訪問多糠,這是不安全的累舷,可以結(jié)合動(dòng)態(tài)配置中心或者數(shù)據(jù)庫(kù)來動(dòng)態(tài)加載允許訪問的域名。
@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;
//允許所有的域訪問夹孔,可以設(shè)置只允許自己的域訪問
response.setHeader("Access-Control-Allow-Origin", "*");
//允許所有方式的請(qǐng)求
response.setHeader("Access-Control-Allow-Methods", "*");
//頭信息緩存有效時(shí)長(zhǎng)(如果不設(shè) Chromium 同時(shí)規(guī)定了一個(gè)默認(rèn)值 5 秒)被盈,沒有緩存將已OPTIONS進(jìn)行預(yù)請(qǐng)求
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、客戶端服務(wù)
因?yàn)閮蓚€(gè)客戶端的是幾乎相同的搭伤,所以這里只展示client1的只怎,詳細(xì)代碼可以到文章開頭那里下載。
2.1 security配置
使用@EnableOAuth2Sso注解怜俐,使用單點(diǎn)登錄身堡,所有的接口都需要登錄之后才可訪問。
@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 定義兩個(gè)接口
定義了一個(gè)測(cè)試接口/test贴谎,至于第二個(gè)接口是回調(diào)接口,當(dāng)客戶端授權(quán)成功后最后一步調(diào)用季稳,這里重定向返回到對(duì)應(yīng)客戶端的前端地址擅这。
@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");
}
}
三、前端項(xiàng)目
1景鼠、授權(quán)中心前端:auth
授權(quán)中心的前端頁(yè)面寫了一個(gè)簡(jiǎn)單的登錄頁(yè)仲翎,當(dāng)點(diǎn)擊登錄按鈕時(shí)調(diào)用login()方法,方法調(diào)用授權(quán)中心后端接口铛漓,如果返回的json的code為0溯香,則登錄成功,然后跳轉(zhuǎn)到授權(quán)中心后端授權(quán)接口浓恶,這里要用window.location.href跳轉(zhuǎn)玫坛,而不能用js調(diào)用,否則無法跳轉(zhuǎn)到客戶端包晰。
<template>
<div>
<p>賬號(hào):</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()方法中調(diào)用客戶端后端接口,如果返回的code為0則顯示數(shù)據(jù)算途,如果返回800塞耕,是未登錄然后跳轉(zhuǎn)到授權(quán)中心的授權(quán)接口,這里的800返回是在授權(quán)中心后端的自定義未登錄 處理UnauthorizedEntryPoint返回的嘴瓤,與標(biāo)準(zhǔn)oauth2流程相比扫外,這里多了一次跳轉(zhuǎn)到授權(quán)接口莉钙,在UnauthorizedEntryPoint然后重定向到授權(quán)中心的登錄頁(yè)。
<template>
<div>
<button v-on:click="test">顯示</button>
<p>client1顯示結(jié)果:{{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>
代碼已經(jīng)準(zhǔn)備好磁玉,一些細(xì)節(jié)的代碼需要從碼云下載了解,在文章就不展示了驾讲,接下來就是測(cè)試了蚊伞。
四、測(cè)試
1吮铭、環(huán)境配置準(zhǔn)備
1.1 配置hosts
在hosts中添加下面三個(gè)域名配置时迫,如果都用localhost來測(cè)試的話,測(cè)試無法知道單點(diǎn)登錄流程是否正常谓晌,因?yàn)槿齻€(gè)項(xiàng)目的域名相同的話cookie可能會(huì)造成干擾掠拳。
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;
}
}
}
注意:配置后端接口時(shí)要加上下面兩句,不然后端重定向時(shí)域名會(huì)變成localhost纸肉,導(dǎo)致流程失敗溺欧。
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1.3 前端打包部署
如何在nginx下打包部署Vue項(xiàng)目,可以看我的這篇文章柏肪。
1.4 啟動(dòng)后端服務(wù)
依次啟動(dòng)nginx姐刁、auth、client1预吆、client2后端服務(wù)龙填。
2、測(cè)試
在瀏覽器輸入http://client1.com/client1Page/home拐叉,訪問客戶端1的前端地址岩遗,點(diǎn)擊顯示按鈕會(huì)跳轉(zhuǎn)到授權(quán)中心的登錄頁(yè),輸入賬號(hào)admin凤瘦,密碼123456宿礁,登錄成功后會(huì)重定向到客戶端1的頁(yè)面,此頁(yè)面地址就是client1后端的的callback接口里設(shè)置的重定向地址蔬芥,然后再點(diǎn)擊按鈕下面會(huì)顯示client1后端接口返回的數(shù)據(jù)梆靖。
然后瀏覽器再開一個(gè)標(biāo)簽頁(yè),輸入http://client2.com/client2Page/home笔诵,訪問客戶端2的前端地址返吻,點(diǎn)擊顯示按鈕然后請(qǐng)求授權(quán)中心授權(quán),然后不需要登錄就授權(quán)成功并重定向到客戶端2的頁(yè)面乎婿,此頁(yè)面地址就是client2后端的callback接口里設(shè)置的重定向地址测僵,這里設(shè)置了相同的頁(yè)面,所以不要錯(cuò)誤認(rèn)為沒有登錄成功,然后點(diǎn)擊顯示按鈕下面會(huì)顯示client2后端返回的數(shù)據(jù)捍靠。
五沐旨、流程解析
1、UML圖
1.1 client1流程
此流程與標(biāo)準(zhǔn)的oauth2流程相比榨婆,多了兩次授權(quán)請(qǐng)求磁携,按照正常oauth2流程,在第一次請(qǐng)求授權(quán)時(shí)如果未登錄就重定向到登錄頁(yè)良风,但用前后端分離后谊迄,返回了授權(quán)接口在前端跳轉(zhuǎn),此時(shí)多了一次授權(quán)請(qǐng)求拖吼,在登錄成功后又再次請(qǐng)求授權(quán)接口鳞上,這樣做的原因是登錄成功后,client2再請(qǐng)求時(shí)無法獲取到登錄成功后的SSO-SESSION這個(gè)cookie吊档,從而導(dǎo)致需要再登錄篙议,我認(rèn)為拿不到cookie的原因是在不同域名下請(qǐng)求另一個(gè)域名的接口是無法取到cookie的,所以只能在瀏覽器上跳轉(zhuǎn)怠硼,授權(quán)中心根據(jù)isRedirect這個(gè)參數(shù)來判斷是重定向到登錄頁(yè)還是返回json未登錄鬼贱。