需求分析
传统的登录系统中,每个站点都实现了自己专用的登录模块。但是各站点的登录状态相互不认可,访问各站点还需要逐一进行手工登录,大大降低了用户的体验感。
?如上图所示,用户每次访问一个系统都需要再次进行授权认证,这样就显得相对繁琐,并且系统代码的重复也会比较高,我们将这样的系统称为多点登录系统。为了解决这一现象,单点登录系统就出现了。
单点登录系统
单点登录,英文是?Single Sign On(缩写为 SSO)。即多个站点共用一台认证授权服务器,这样用户在任何一个站点登录后,就可以避免再次登录才能访问其它的站点。并且各站点间可以通过该登录状态直接交互。
准备工作
项目结构
?
修改网关配置文件
在原网关配置中添加如下配置:
- id: router02
uri: lb://sca-auth
predicates:
#- Path=/auth/login/** #刚开始时这里必须是login,因为是默认的,后续可以更改
- Path=/auth/oauth/** #微服务架构(更改之后),需要令牌
filters:
- StripPrefix=1
现网关配置文件内容:
server:
port: 9000
spring:
application:
name: sca-resource-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
config:
server-addr: localhost:8848
file-extension: yml
gateway:
discovery:
locator:
enabled: true
routes:
- id: router01
uri: lb://sca-resource
predicates:
- Path=/sca/resource/upload/**
filters:
- StripPrefix=1
- id: router02
uri: lb://sca-auth
predicates:
#- Path=/auth/login/** #刚开始时这里必须是login,因为是默认的,后续可以更改
- Path=/auth/oauth/** #微服务架构(更改之后),需要令牌
filters:
- StripPrefix=1
globalcors: #跨域配置
corsConfigurations:
'[/**]':
allowedOrigins: "*"
allowedHeaders: "*"
allowedMethods: "*"
allowCredentials: true
sentinel:
transport:
dashboard: localhost:8180
port: 8719
eager: true
添加相关依赖
在权限认证项目pom文件中添加如下依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
现 pom文件内容:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>02-sca-files</artifactId>
<groupId>com.jt</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>sca-auth</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project>
构建权限认证项目配置文件
在sca-auth工程中创建bootstrap.yml文件,内容如下:
server:
port: 8071
spring:
application:
name: sca-auth #定义nacos服务名称
cloud:
nacos:
discovery:
server-addr: localhost:8848
config:
server-addr: localhost:8848
前端UI设计
前端UI设计工程项目结构:
之所以有两个登录界面,是因为login.html是用于postman测试的,而login-sso.html实现了可以直接在浏览器直接输入地址访问的功能.用户登录之后,可以进入到文件上传界面.
各自的HTML内容如下:
1)fileupload.html文件内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>文件上载演示</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
ul>li{
list-style-type: none;
}
</style>
</head>
<body>
<h1>文件上传案例演示:</h1>
<form id="fileForm" method="post"
enctype="multipart/form-data"
onsubmit="return doUpload()">
<div>
<ul>
<li><input id="uploadFile" type="file" name="uploadFile"></li>
<li><button type="submit">上传文件</button></li>
</ul>
</div>
</form>
</body>
<script>
//jquery代码的表单提交事件
function doUpload(){
debugger //debug窗口打开以后调试代码
//获得用户选中的所有图片(获得数组)
let files=document.getElementById("uploadFile").files;
if(files.length>0){
//获得用户选中的唯一文件(从数组中取出)
let file=files[0];
//开始上传这个文件
//由于上传代码比较多,不想和这里其它代码干扰,所以定义一个方法调用
upload(file);
}
//阻止表单提交效果
return false;
};
// 将file上传到服务器的方法
function upload(file){
//定义一个表单
let form=new FormData();
//将图片添加到表单中
form.append("uploadFile",file);
let url="http://localhost:9000/sca/resource/upload/";
//异步提交方式1
axios.post(url,form)
.then(function (response){
alert("upload ok")
console.log(response.data);
})
.catch(function (e){//失败时执行catch代码块
console.log(e);
})
//异步提交方式2
// axios({
// url:"http://localhost:8881/resource/upload/",
// method:"post",
// data:form
// }).then(function(response){
// alert("upload ok")
// console.log(response.data);
// })
}
</script>
</html>
2)login.html文件内容:
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<title>login</title>
</head>
<body>
<div class="container"id="app">
<h3>Please Login</h3>
<form>
<div class="mb-3">
<label for="usernameId" class="form-label">Username</label>
<input type="text" v-model="username" class="form-control" id="usernameId" aria-describedby="emailHelp">
</div>
<div class="mb-3">
<label for="passwordId" class="form-label">Password</label>
<input type="password" v-model="password" class="form-control" id="passwordId">
</div>
<button type="button" @click="doLogin()" class="btn btn-primary">Submit</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
var vm=new Vue({
el:"#app",//定义监控点,vue底层会基于此监控点在内存中构建dom树
data:{ //此对象中定义页面上要操作的数据
username:"",
password:""
},
methods: {//此位置定义所有业务事件处理函数
doLogin() {
//1.定义url
let url = "http://localhost:9000/auth/login"
//2.定义参数
let params = new URLSearchParams()
params.append('username',this.username);
params.append('password',this.password);
//3.发送异步请求
axios.post(url, params).then((response) => {
debugger
let result=response.data;
console.log(result);
if (result.state == 200) {
alert("login ok");
location.href="/fileupload.html"
} else {
alert(result.message);
}
})
}
}
});
</script>
</body>
</html>
3)login-sso.html文件内容:
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<title>login</title>
</head>
<body>
<div class="container"id="app">
<h3>Please Login</h3>
<form>
<div class="mb-3">
<label for="usernameId" class="form-label">Username</label>
<input type="text" v-model="username" class="form-control" id="usernameId" aria-describedby="emailHelp">
</div>
<div class="mb-3">
<label for="passwordId" class="form-label">Password</label>
<input type="password" v-model="password" class="form-control" id="passwordId">
</div>
<button type="button" @click="doLogin()" class="btn btn-primary">Submit</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
var vm=new Vue({
el:"#app",//定义监控点,vue底层会基于此监控点在内存中构建dom树
data:{ //此对象中定义页面上要操作的数据
username:"",
password:""
},
methods: {//此位置定义所有业务事件处理函数
doLogin() {
//1.定义url
//let url = "http://localhost:9000/auth/login"
let url = "http://localhost:9000/auth/oauth/token"
//2.定义参数
let params = new URLSearchParams()
params.append('username',this.username);
params.append('password',this.password);
params.append('client_id','gateway-client');
params.append('grant_type','password');
params.append('client_secret',"123456");
// debugger
//3.发送异步请求
axios.post(url, params).then((response) => {
debugger
console.log(response.data);
let result = response.data;
localStorage.setItem("accessToken",result.access_token);
location.href="/fileupload.html"
})
}
}
});
</script>
</body>
</html>
注意区分两个登录文件的区别. login-sso.html主要是已经实现了参数的传递.
代码实现
权限认证工程项目结构
配置类内容
TokenConfig类
创建jwt令牌配置类,基于这个类实现令牌的创建和解析
package com.jt.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
/**
* 创建jwt令牌配置类,基于这个类实现令牌的创建和解析
* jwt令牌由3个部分构成
* 1)HEADER(头部信息:令牌信息)
* 2)PAYLOAD(数据信息-用户信息,权限信息)
* 3)SIGNATURE(签名信息-对header和payload部分进行加密)*/
@Configuration
public class TokenConfig {
//定义令牌签发口令:当客户端在执行登录时,假如有携带这个信息,认证服务器就可以给他签发一个令牌
//在对header和payload部分进行签名时,需要的一个口令
private String SIGNING_KEY = "auth";
//构建令牌生成器对象(构建和存储令牌)
@Bean
public TokenStore tokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter());
}
//jwt转换器,将任何数据转换为jwt字符串令牌
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
//设置加密/解密口令
converter.setSigningKey(SIGNING_KEY);
return converter;
}
}
SecurityConfig类
定义登录的规则(成功登录时的返回信息与登录失败时的返回信息),创建认证管理器对象
package com.jt.auth.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//密码加密对象
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//在这个方法中定义登录规则
//1.对所有请求放行(当前工程只做认证)
//2.登陆成功信息的返回
//3.登录失败信息的返回
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭跨域攻击
http.csrf().disable();
//放行所有请求
http.authorizeRequests().anyRequest().permitAll();
//登录成功与失败的返回
http.formLogin()
.successHandler(successHandler())
.failureHandler(failureHandler());
}
@Bean
public AuthenticationSuccessHandler successHandler(){
return (request, response, authentication)->{
//1.构建map对象,封装响应数据
Map<String,Object> map = new HashMap<>();
map.put("state", 200);
map.put("message", "login ok");
//2.将对象转换为JSON,并写到客户端
writeJsonToClient(response, map);
};
}
@Bean
public AuthenticationFailureHandler failureHandler(){
return (request, response, authentication) -> {
//1.构建map对象,封装响应数据
Map<String,Object> map = new HashMap<>();
map.put("state", 500);
map.put("message", "login failure");
//2.将对象转换为JSON,并写到客户端
writeJsonToClient(response, map);
};
}
private void writeJsonToClient(HttpServletResponse response,Object object) throws IOException {
//2.将对象转换为JSON
//Gson-->toJson (需要自己找依赖)
//fastjson-->JSON (spring-cloud-starter-alibaba-sentinel)
//jackson-->writeValueAsString (spring-boot-starter-web)
String jsonStr = new ObjectMapper().writeValueAsString(object);
//3.将json字符串写到客户端
PrintWriter writer = response.getWriter();
writer.println(jsonStr);
writer.flush();
}
//创建认证管理器对象(此对象主要负责对客户端输入的用户信息进行认证),后面授权服务器会用到
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
UserDetailsServiceImpl类
登录时用户信息的获取和封装会在此对象进行实现.
package com.jt.auth.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 登录时用户信息的获取和封装会在此对象进行实现,
* 在页面点击登录按钮时会调用这个对象的loadUserByUsername方法,
* 页面上输入的用户名会传给这个方法的参数
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
//UserDetails用户封装用户信息(认证和权限信息)
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
//1.基于用户名查询用户信息(用户名,用户密码,用户状态...)
//Userinfo userinfo = userMapper.selectUserByUsername(username);
String encodePassword = passwordEncoder.encode("123456");
//2.查询用户权限信息,这里给的是假数据
List<GrantedAuthority> authorities =
AuthorityUtils.createAuthorityList(//这里的权限信息先这么写,后面再讲其它
"sys:res:create", "sys:res:retrive");
//3.对用户信息进行封装
return new User(username, encodePassword, authorities);
}
}
Oauth2Config类
完成所有配置的组装,在这个配置类中完成认证授权,JWT令牌签发等配置操作.
package com.jt.auth.config;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import java.util.Arrays;
/**完成所有配置的组装,在这个配置类中完成认证授权,JWT令牌签发等配置操作*/
@AllArgsConstructor
@EnableAuthorizationServer//开启认证和授权服务
@Configuration
public class Oauth2Config extends AuthorizationServerConfigurerAdapter {
//此对象负责完成认证管理
private AuthenticationManager authenticationManager;
//负责完成令牌的创建,信息读取等
private TokenStore tokenStore;
//负责获取用户的详情信息(username,password,client_id,grand_type,client_secret)
private ClientDetailsService clientDetailsService;
//jwt令牌转换器(基于用户信息构建令牌和解析令牌)
private JwtAccessTokenConverter jwtAccessTokenConverter;
//密码加密匹配器对象
private PasswordEncoder passwordEncoder;
//负责获取用户详细信息
private UserDetailsService userDetailsService;
/***/
//设置认证端点的配置(/oauth/token)
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//配置认证管理器
.authenticationManager(authenticationManager)
//验证用户的方法获得用户详情
.userDetailsService(userDetailsService)
//要求提交认证使用post请求方式,提高安全性
.allowedTokenEndpointRequestMethods(HttpMethod.POST,HttpMethod.GET)
//要配置令牌的生成,由于令牌生成比较复杂,下面有方法实现
.tokenServices(tokenService());//这个被注释的话,默认令牌为uuid的
}
//定义令牌生成策略
@Bean
public AuthorizationServerTokenServices tokenService(){
//这个方法的目标就是获得一个令牌生成器
DefaultTokenServices services=new DefaultTokenServices();
//支持令牌刷新策略(令牌有过期时间)
services.setSupportRefreshToken(true);
//设置令牌生成策略(tokenStore在TokenConfig配置了),使用的是jwt
services.setTokenStore(tokenStore);
//设置令牌增强(固定用法-令牌Payload部分允许添加扩展数据,例如用户权限信息)
TokenEnhancerChain chain=new TokenEnhancerChain();
chain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter));
//令牌增强对象设置到令牌生成
services.setTokenEnhancer(chain);
//设置令牌有效期
services.setAccessTokenValiditySeconds(3600);//1小时
//刷新令牌应用场景:一般在用户登录系统后,令牌快过期时,系统自动帮助用户刷新令牌,提高用户的体验感
services.setRefreshTokenValiditySeconds(3600*72);//3天
//配置客户端详情
services.setClientDetailsService(clientDetailsService);
return services;
}
// 设置客户端详情类似于用户详情
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
//客户端id
.withClient("gateway-client")
//客户端秘钥
.secret(passwordEncoder.encode("123456"))
//设置权限
.scopes("all")//all只是个名字而已和写abc效果相同
//允许客户端进行的操作 里面的字符串千万不能写错
.authorizedGrantTypes("password","refresh_token");
}
// 认证成功后的安全约束配置
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
//认证通过后,允许客户端进行哪些操作
security
//公开oauth/token_key端点
.tokenKeyAccess("permitAll()")
//公开oauth/check_token端点
.checkTokenAccess("permitAll()")
//允许提交请求进行认证(申请令牌)
.allowFormAuthenticationForClients();
}
}
测试阶段
Postman测试jwt令牌获取
先成功启动好nacos和sentinel.再进行postman测试.
在postman里先输入地址?http://localhost:9000/auth/oauth/token?,然后点击下面的 Params 以进行参数的配置(也可以直接在地址栏输入参数,但为了直观与减小错误率,建议不这么做).具体参数内容如下图:
测试成功后上图结果区域的内容如下:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzAwNjk3OTYsInVzZXJfbmFtZSI6ImphY2siLCJhdXRob3JpdGllcyI6WyJzeXM6cmVzOmNyZWF0ZSIsInN5czpyZXM6cmV0cml2ZSJdLCJqdGkiOiIwY2I5M2YyYS0xZjEzLTQxZDAtYmJmMi0xNGY4MDE2ZWNkYjUiLCJjbGllbnRfaWQiOiJnYXRld2F5LWNsaWVudCIsInNjb3BlIjpbImFsbCJdfQ.DUtdhGLSr-64JweJrOuxoPe-5mS62A1it--Z35_Rz0k",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJqYWNrIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6IjBjYjkzZjJhLTFmMTMtNDFkMC1iYmYyLTE0ZjgwMTZlY2RiNSIsImV4cCI6MTYzMDMyNTM5NiwiYXV0aG9yaXRpZXMiOlsic3lzOnJlczpjcmVhdGUiLCJzeXM6cmVzOnJldHJpdmUiXSwianRpIjoiYjRkMWVlZGItNDc1Mi00ODFmLTk2NTAtNjczOGI4YWYzZTVlIiwiY2xpZW50X2lkIjoiZ2F0ZXdheS1jbGllbnQifQ.fofDznmSjz2QuJmRZDBjc_NBdJPlFMD0a6rYaPUBLcY",
"expires_in": 3600,
"scope": "all",
"jti": "0cb93f2a-1f13-41d0-bbf2-14f8016ecdb5"
}
浏览器测试登录流程
浏览器地址栏输入以下网址?http://localhost:8080/login-sso.html.
注意:这里也可以访问?http://localhost:8080/login.html,但是输入用户名和密码之后不能提交,因为需要的参数它还没有传递.
用户名和密码输入完成后,点击登录提交按钮,因为我们在login-sso.html里面设置了debugger,所以可以测试查看控制端输出的内容(必须先F12打开控制台才能进入到debugger阶段)
可以看到输出的结果和postman的结果是一样的.
登录成功后会切换到如下页面:
到这里,测试就已经成功了.
|