大家好,我是技術(shù)UP主小傅哥,。
當(dāng)你進入一個較大一些的中大廠互聯(lián)網(wǎng)公司以后,,你會發(fā)現(xiàn)自己參與的業(yè)務(wù)系統(tǒng)開發(fā),好像從來沒有關(guān)心過關(guān)于用戶的身份鑒權(quán),,而是直接拿到用戶的ID就做業(yè)務(wù)了,。那這里的鑒權(quán)跑到哪里去了呢???
其實在公司里是一套統(tǒng)一的授權(quán)服務(wù)和組件的,,并且維護用戶的ID,、用戶的關(guān)聯(lián)綁定也都是這套系統(tǒng)來處理的。之后這套系統(tǒng)會和 API 網(wǎng)關(guān)進行對接,,等網(wǎng)關(guān)下發(fā)到你的后端服務(wù)系統(tǒng)時,,在內(nèi)部微服間流轉(zhuǎn)就是真實的用戶ID啦。
那么為了讓伙伴們更好的理解關(guān)于 OAuth2 SSO 統(tǒng)一單點登錄的前后端分離服務(wù),,小傅哥這里做了一個結(jié)合 Spring Security OAuth2很容易理解案例工程,。學(xué)習(xí)后就可以擴展使用 SSO 到你自己的系統(tǒng)了,比如可以做一個統(tǒng)一的用戶鑒權(quán)中心,。
一,、單點登錄
單點登錄(Single Sign-On,SSO)是一種認(rèn)證技術(shù),,用戶只需進行一次身份驗證,,就可以訪問多個相互信任的應(yīng)用系統(tǒng),,而無需再次輸入憑證。SSO的主要目的是簡化用戶的登錄過程,,提高用戶體驗和安全性,,同時減少管理多個用戶名和密碼的復(fù)雜性。
SSO的工作原理通常涉及以下幾個步驟:
- 身份驗證:用戶在第一次訪問SSO系統(tǒng)時輸入用戶名和密碼等憑據(jù)進行驗證,。
- 創(chuàng)建會話:成功驗證后,,系統(tǒng)創(chuàng)建一個會話,可以是令牌,、票證或其他憑據(jù),以證明用戶的身份,。
- 訪問授權(quán):當(dāng)用戶訪問不同的應(yīng)用時,,SSO系統(tǒng)將會話信息傳遞給這些應(yīng)用,以確認(rèn)用戶的身份并授予訪問權(quán)限,。
- 信任機制:應(yīng)用之間需要建立信任關(guān)系,,通常通過共享密鑰或使用公鑰基礎(chǔ)設(shè)施(PKI)來實現(xiàn)驗證和授權(quán)。
SSO的優(yōu)點包括:
- 提高用戶體驗:用戶只需記住一個用戶名和密碼,,減少了填寫登錄信息的次數(shù),。
- 增強安全性:集中管理用戶身份,方便監(jiān)控和保護密碼策略,。
- 降低管理成本:減少IT部門處理密碼重置等事務(wù)的工作量,。
二、案例工程
1. 編程環(huán)境
Docker - 負(fù)責(zé)安裝 Nginx,,如果沒有 Docker 就本地直接安裝 Nginx
SwitchHosts - 切換host,,映射自定義域名地址,可以避免跨域問題,。如果沒有就直接修改本地的 host 文件,。你可以配置自己的。
192.168.1.107 sso.
192.168.1.107 client1.
192.168.1.107 client2.
- 工程:https://github.com/fuzhengwei/xfg-dev-tech-oauth2-sso
2. 工程結(jié)構(gòu)
- xfg-dev-tech-app 是 SSO Auth 的鑒權(quán)服務(wù),。
- test 模塊下有2個 client,,方便驗證一個登錄成功后,另外一個不會再跳轉(zhuǎn)登錄了,。
- docs/dev-ops 下提供了 docker compose 腳本,,用于部署 Nginx 以及配合的前后端分離的前端頁面。
3. 鑒權(quán)服務(wù)
server:
port: 8091
application:
name: xfg-dev-tech-sso
servlet:
context-path: /auth
session:
cookie:
name: OAuth2SSOToken
- yml 配置了 auth 路徑和一個 session 名稱,。
3.1 鑒權(quán)配置
AuthorizationServerConfig
@Bean
public ClientDetailsService inMemoryClientDetailsService() throws Exception {
return new InMemoryClientDetailsServiceBuilder()
// client1 mall
.withClient("client1")
.secret(passwordEncoder.encode("client1_secret"))
.scopes("all")
.authorizedGrantTypes("authorization_code", "refresh_token")
.redirectUris("http://client1./client1/login")
.accessTokenValiditySeconds(7200)
.autoApprove(true)
.and()
// client2 lottery
.withClient("client2")
.secret(passwordEncoder.encode("client2_secret"))
.scopes("all")
.authorizedGrantTypes("authorization_code", "refresh_token")
.redirectUris("http://client2./client2/login")
.accessTokenValiditySeconds(7200)
.autoApprove(true)
.and()
.build();
}
- 配置鑒權(quán)信息,,這里配置了兩個客戶端信息。
3.2 驗證入口
@Component("unauthorizedEntryPoint")
public class AppUnauthorizedEntryPoint 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.parseBoolean(isRedirectValue)) {
response.sendRedirect("http://sso./authPage/#/login?" + param);
return;
}
String authUrl = "http://sso./auth/oauth/authorize?" + param + "&isRedirect=true";
Map<String, Object> result = new HashMap<>();
result.put("code", 800);
result.put("msg", "授權(quán)地址");
result.put("data", authUrl);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
PrintWriter writer = response.getWriter();
ObjectMapper mapper = new ObjectMapper();
writer.print(mapper.writeValueAsString(result));
writer.flush();
writer.close();
}
}
- 需要實現(xiàn) AuthenticationEntryPoint 接口,,配置一個轉(zhuǎn)發(fā)的地址,。
4. 客戶端 - client1/client2
4.1 client1
server:
port: 8001
servlet:
context-path: /client1
security:
oauth2:
client:
client-id: client1
preEstablishedRedirectUri:
client-secret: client1_secret
access-token-uri: http://sso./auth/oauth/token
user-authorization-uri: http://sso./auth/oauth/authorize
resource:
user-info-uri: http://sso./auth/user
token-info-uri: http://sso./auth/oauth/check_token
@RestController
public class Client01Controller {
@GetMapping("/create_pay_order")
public Result createPayOrder() {
Result result = new Result();
result.setCode(0);
result.setData("下單完成");
return result;
}
@GetMapping("/")
public void callback(HttpServletResponse response) throws IOException {
response.sendRedirect("http://client1./client1Page/#/home");
}
}
4.2 client2
server:
port: 8002
servlet:
context-path: /client2
security:
oauth2:
client:
client-id: client2
client-secret: client2_secret
preEstablishedRedirectUri:
access-token-uri: http://sso./auth/oauth/token
user-authorization-uri: http://sso./auth/oauth/authorize
resource:
user-info-uri: http://sso./auth/user
token-info-uri: http://sso./auth/oauth/check_token
@RestController
public class Client02Controller {
@GetMapping("/lottery")
public Result lottery() {
Result result = new Result();
result.setCode(0);
result.setData("下單紅包,,金額:" + RandomStringUtils.randomNumeric(10) + "元");
return result;
}
@GetMapping("/")
public void callback(HttpServletResponse response) throws IOException {
response.sendRedirect("http://client2./client2Page/#/home");
}
}
- 模擬另外一個微服務(wù)獲取紅包,,以及 callback 地址服務(wù)。
5. 前端頁面
5.1 校驗
<div class="login-container">
<h2>登錄</h2>
<input type="text" id="username" placeholder="用戶名" required>
<input type="password" id="password" placeholder="密碼" required>
<button id="login-btn">登錄</button>
</div>
<script src="https://cdn./npm/axios/dist/axios.min.js"></script>
<script>
const base = 'http://sso.'; // 設(shè)置你的基礎(chǔ)URL
document.getElementById('login-btn').addEventListener('click', function() {
const loginForm = {
username: document.getElementById('username').value,
password: document.getElementById('password').value
};
postRequest('/auth/login', loginForm).then(resp => {
if (resp.data.code === 0) {
const pageUrl = window.location.href;
const param = pageUrl.split('?')[1];
window.location.href = '/auth/oauth/authorize?' + param;
} else {
console.log('登錄失?。?#39; + resp.data.msg);
}
});
});
function postRequest(url, params) {
return axios({
method: 'post',
url: `${base}${url}`,
data: params,
transformRequest: [function (data) {
let ret = '';
for (let it in data) {
ret += encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&';
}
return ret;
}],
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
}
</script>
- 登錄跳轉(zhuǎn)操作,,這里會走到 Nginx 中進行轉(zhuǎn)發(fā)。
5.2 客戶端01
<div>
<button id="testButton">開始下單</button>
<p id="result">下單結(jié)果:</p>
</div>
<script src="https://cdn./npm/axios/dist/axios.min.js"></script>
<script>
const base = 'http://client1.';
function getRequest(url) {
return axios.get(`${base}${url}`);
}
document.getElementById('testButton').addEventListener('click', function() {
getRequest('/client1/create_pay_order').then(resp => {
const resultElement = document.getElementById('result');
if (resp.data.code === 0) {
const linkHtml = " <a href='http://client2./client2Page/#/home'>領(lǐng)紅包</a>";
resultElement.innerHTML = resp.data.data + linkHtml;
} else if (resp.data.code === 800) {
window.location.href = resp.data.data;
} else {
console.log('失?。?#39; + resp.data);
}
}).catch(error => {
console.log('請求失?。?#39;, error);
});
});
</script>
- 下單的時候會檢查是否登錄,否則會被調(diào)整到 auth 校驗,。
5.2 客戶端02
<div>
<button id="testButton">隨機紅包</button>
<p id="result">紅包結(jié)果:</p>
</div>
<script src="https://cdn./npm/axios/dist/axios.min.js"></script>
<script>
const base = 'http://client2.';
function getRequest(url) {
return axios.get(`${base}${url}`);
}
document.getElementById('testButton').addEventListener('click', function() {
getRequest('/client2/lottery').then(resp => {
const resultElement = document.getElementById('result');
if (resp.data.code === 0) {
resultElement.textContent = resp.data.data;
} else if (resp.data.code === 800) {
window.location.href = resp.data.data;
} else {
console.log('失?。?#39; + resp.data);
}
}).catch(error => {
console.log('請求失敗:', error);
});
});
</script>
- 與 client1 的操作是一樣的,,但這里只要有一個登錄了,,另外一個就不會調(diào)整到 auth 頁面登錄了。
6. Nginx 配置
- Nginx 配置結(jié)構(gòu),,docker compose 啟動的時候會進行安裝,。
6.1 sso.conf
server {
listen 80;
server_name sso.;
location /auth/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.1.107:8091/auth/;
}
location /authPage/ {
alias /usr/share/nginx/html/;
index auth.html;
}
location ~ .*\.(js|css)$ {
alias /usr/share/nginx/html/;
index auth.html;
}
}
6.2 client1.conf
server {
listen 80;
server_name client1.;
location /client1/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.1.107:8001/client1/;
}
location /client1Page/ {
alias /usr/share/nginx/html/;
index client1.html;
}
location ~ .*\.(js|css)$ {
alias /usr/share/nginx/html/;
index client1.html;
}
}
6.3 client2.conf
server {
listen 80;
server_name client2.;
location /client2/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.1.107:8002/client2/;
}
location /client2Page/ {
alias /usr/share/nginx/html/;
index client2.html;
}
location ~ .*\.(js|css)$ {
alias /usr/share/nginx/html/;
index client2.html;
}
}
更多的代碼從工程中閱讀即可,復(fù)雜度不高,。
三,、測試驗證
1. 啟動服務(wù)
- 你需要啟動 Docker 的 Nginx,之后順序啟動 SSO 服務(wù)和2個客戶端服務(wù),。
- 另外要配置好 host,,這樣訪問你的自定義域名地址,才會正確的跳轉(zhuǎn),。(這東西在日常公司開發(fā)中會用到的很頻繁)
2. 訪問客戶端
你可以訪問地址1進行驗證,,登錄之后也可以進入地址2進行驗證;
- http://client1./client1Page/#/home
- http://client2./client2Page/#/home