一、OAuth基本概念
1、什么是OAuth2.0
OAuth2.0是一个开放标准,允许用户授权第三方应用程序访问他们存储在另外的服务提供者上的信息,而不需要将用户和密码提供给第三方应用或分享数据的所有内容。
2、四种认证方式
1)授权码模式
2)简化模式
3)密码模式
4)客户端模式
普通令牌只是一个随机的字符串,没有特殊的意义,当客户带上令牌去访问应用的接口时,应用本身无法判断这个令牌是否正确,就需要到授权服务器上判断令牌。高并发下,检查令牌的网络请求就有可能成为一个性能瓶颈。
改良的方式:JWT令牌,将令牌对应的相关信息全部冗余到令牌本身,这样资源服务器就不再需要发送请求给授权服务器去检查令牌,自己就可以读取到令牌的授权信息。JWT令牌的本质就是一个加密的字符串。
3、联合登录和单点登录
单点登录
联合登录
4、实例流程
用户-百度-微信
官方
- 客户端(Client):浏览器、微信客户端--本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源
- 资源拥有者(ResourceOwner):通常是用户,也可以应用程序
- 授权服务器(AuthorizationServer):用于服务提供者对资源拥有的身份进行认证,对访问资源进行授权,认证成功后会给客户端发放令牌,作为客户端访问资源服务器的凭据。
- 资源服务器(ResourceServer):存储资源的服务器,例如微信通过OA协议让百度获取到自己存储的用户信息,而百度通过OA协议,让用户可以访问自己的受保护资源。
二、SpringSecurity基本概念
1、认证
用户认证就是判断一个用户的身份是否合法的过程,用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问,不合法则拒绝访问。
认证是为了保护系统的隐私数据与资源,用户的身份合法方可访问该系统的资源。
2、授权
授权是用户认证通过后,根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。
认证是为了保证用户身份的合法性,授权则是为了更细粒度的对隐私数据进行划分,授权是在认证通过后发生的,控制不同的用户能够访问不同的资源。
3、会话
用户认证通过后,为了避免用户的每次操作都进行认证可将用户的信息保证在会话中。会话就是系统为了保持当前用户的登录状态所提供的机制,常见的有基于session方式、基于token方式等。
三、简单的权限模型
1、建表
CREATE TABLE `role` (
`id` int NOT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
INSERT INTO `role` VALUES (1, 'mobile');
INSERT INTO `role` VALUES (2, 'salary');
CREATE TABLE `source` (
`id` int NOT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`source` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
INSERT INTO `source` VALUES (1, 'admin', 'mobile');
INSERT INTO `source` VALUES (2, 'admin', 'salary');
INSERT INTO `source` VALUES (3, 'manage', 'mobile');
CREATE TABLE `user` (
`id` int NOT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`pass` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
INSERT INTO `user` VALUES (1, 'admin', 'admin');
INSERT INTO `user` VALUES (2, 'manage', 'manage');
2、搭建-properties依赖、构建实体类、连接数据库
3、实现
1)LoginRequest
@Data
public class LoginRequest {
@JsonProperty("name")
private String name;
@JsonProperty("pass")
private String pass;
}
2)AuthService
public interface AuthService {
UserDO userLogin(LoginRequest request);
List<String> havaPermission(UserDO userDO);
}
3)AuthServiceImpl
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
private UserMapper userMapper;
@Autowired
private SourceMapper sourceMapper;
@Override
public UserDO userLogin(LoginRequest request) {
System.out.println(request);
UserDO userDO = userMapper.selectOne(new LambdaQueryWrapper<UserDO>()
.eq(UserDO::getName, request.getName())
.eq(UserDO::getPass, request.getPass())
);
return userDO;
}
@Override
public List<String> havaPermission(UserDO userDO) {
List<SourceDO> sourceDOS = sourceMapper.selectList(new LambdaQueryWrapper<SourceDO>().eq(SourceDO::getName, userDO.getName()));
List<String> collect = sourceDOS.stream().map(obj -> obj.getSource()).collect(Collectors.toList());
return collect;
}
}
4)SalaryController
@RestController
@RequestMapping("/salary")
public class SalaryController {
@GetMapping("/query")
public String query() {
return "salary";
}
}
5)MobileController
@RestController
@RequestMapping("/mobile")
public class MobileController {
@GetMapping("/query")
public String query() {
return "mobile";
}
}
6)LoginController
@Slf4j
@RestController
@RequestMapping("/loginController")
public class LoginController {
@Resource
private AuthServiceImpl authService;
@PostMapping("/login")
public UserDO login(@RequestBody LoginRequest request,
HttpServletRequest httpServletRequest,
HttpServletResponse response
) {
UserDO user = authService.userLogin(request);
if (null != user) {
log.info("user login succeed");
httpServletRequest.getSession().setAttribute("currentUser", user);
System.out.println((httpServletRequest.getSession()));
}else {
log.info("user login failed");
}
return user;
}
@PostMapping("/getCurrentUser")
public Object getCurrentUser(HttpSession session) {
return session.getAttribute("currentUser");
}
@PostMapping("/logout")
public void logout(HttpSession session) {
session.removeAttribute("currentUser");
}
@PostMapping("/havaPermission")
public List<String> havaPermission(UserDO userDO){
return authService.havaPermission(userDO);
}
}
7)MyWebAppConfigurer
@Component
public class MyWebAppConfigurer implements WebMvcConfigurer {
@Resource
private AuthInterceptor authInterceptor;
/**
* 配置权限拦截器
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor).addPathPatterns("/**");
}
/**
* 简单配置启动页面
*
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("redirect:/index.html");
}
}
8)拦截器 -- 登录以后才可以访问
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
AuthService authService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler
) throws Exception {
//1、不需要登录就可以访问的路径
String requestURI = request.getRequestURI();
if (requestURI.contains(".") || requestURI.startsWith("/" + "loginController")) {
return true;
}
//2、未登录用户,直接拒绝访问
if (null == request.getSession().getAttribute("currentUser")) {
response.setCharacterEncoding("UTF-8");
response.getWriter().write("please login first");
return false;
} else {
UserDO currentUser = (UserDO) request.getSession().getAttribute("currentUser");
List<String> strings = authService.havaPermission(currentUser);
//3、已登录用户,判断是否有资源访问权限
if (requestURI.startsWith("/" + "mobile" + "/") && strings.contains("mobile")) {
return true;
} else if (requestURI.startsWith("/" + "salary" + "/") && strings.contains("salary")) {
return true;
} else {
response.setCharacterEncoding("UTF-8");
response.getWriter().write("no auth to visit");
return false;
}
}
}
}
9)html
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form id="loginForm" method="post">
<label for="name">name:</label>
<input type="text" id="name" name="name" required><br>
<label for="pass">pass:</label>
<input type="pass" id="pass" name="pass" required><br>
<button type="submit">Login</button>
</form>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="script.js"></script>
</body>
</html>
$(document).ready(function() {
// 监听表单提交事件
$("#loginForm").submit(function(event) {
// 阻止表单默认提交行为
event.preventDefault();
// 获取用户名和密码
var name = $("#name").val();
var pass = $("#pass").val();
// 发送登录请求
$.ajax({
url: "http://localhost:端口号/loginController/login",
type: "POST",
data: JSON.stringify({name: name, pass: pass}),
contentType: "application/json",
success: function(response) {
// 登录成功处理
console.log("Login successful");
// 可以在此处跳转到其他页面
},
error: function(xhr, status, error) {
// 登录失败处理
console.log("Login failed");
console.log(xhr.responseText);
}
});
});
});
这样一个简单的demo就完成了,接下来测试。
http://localhost:端口号/index.html
进入登录界面后,输入账号密码,这边ajax没有跳转其他界面,只是为了获取Set-Cookie,也就是session,再次访问http://localhost:1223/mobile/query
的时候 就可以查看到mobile的信息了。
四、拓展
基于上述的父工程,创建子模块。复用MobileController、SalaryController。
1、依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2、注解
启动类加注解@EnableWebSecurity
3、简单的启动
登录用户名是user
,密码在启动控制台中会显示。
4、注入密码解析器及用户来源
1)MyWebConfig
通过注入一个PasswordEncoder对象实现密码加密。包括CryptPassEncoder、Argon2PasswordEncoder、Pbkdf2PasswordEncoder等。
通过注入一个UserDetailService来管理系统的实体数据,如果不自己注入,在UserDetailsServiceAutoConfiguration中会默认注入一个包含user用户的UserDetailService。在SpringSecurity中,也提供了JdbcUserDetailsManager来实现对数据库的用户信息进行管理。
@Configuration
public class MyWebConfig implements WebMvcConfigurer {
/**
* 默认Url根路径跳转到/login,此url为spring security提供
*
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("redirect:/login");
}
/**
* 加密
*
* @return
*/
@Bean
public PasswordEncoder getPassWordEncoder() {
return new BCryptPasswordEncoder(10);
// return NoOpPasswordEncoder.getInstance();
}
/**
* 自行注入一个UserDetailsService
* 如果没有的话,在UserDetailsServiceAutoConfiguration中会默认注入一个包含user用户的InMemoryUserDetailsManager
* 另外也可以采用修改configure(AuthenticationManagerBuilder auth)方法并注入authenticationManagerBean的方式。
*
* @return
*/
@Bean
public UserDetailsService userDetailsService() {
// // 创建数据源
// DataSource dataSource = new DruidDataSource();
// // 设置数据库连接信息
// ((DruidDataSource) dataSource).setUrl("jdbc:mysql://localhost:3306/sys");
// ((DruidDataSource) dataSource).setUsername("root");
// ((DruidDataSource) dataSource).setPassword("root");
// ((DruidDataSource) dataSource).setDriverClassName("com.mysql.cj.jdbc.Driver");
// // 将 DataSource 传递给 JdbcUserDetailsManager 根据接口方式进行拓展,表结构不同
// return new JdbcUserDetailsManager(dataSource);
// //自定义
// return new MyUserService();
//自定义一个Manager,没连接数据库
InMemoryUserDetailsManager userDetailsManager =
new InMemoryUserDetailsManager(User.withUsername("admin")
.password(getPassWordEncoder().encode("admin"))
.authorities("mobile", "salary")
.build(),
User.withUsername("manager").password("manager").authorities("salary").build(),
User.withUsername("worker").password("worker").authorities("mobile").build());
return userDetailsManager;
}
}
2)MyUserService
public class MyUserService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//假数据 需要在myWebConfig中注入
if("admin".equals(username)){
return User.withUsername("admin")
.password("admin")
.authorities("mobile", "salary")
.build();
}
return null;
}
}
5、注入校验配置规则
MyWebSecurityConfig
@EnableWebSecurity
//public class MyWebSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
//关闭csrg跨域检查
http.csrf().disable()
.authorizeRequests()
//配置资源权限
.antMatchers("/**.html", "/css/**").permitAll()
.antMatchers("/mobile/**").hasAuthority("mobile")
.antMatchers("/salary/**").hasAuthority("salary")
//loginController下的请求直接通过
.antMatchers("/loginController/**").permitAll()
//其他请求需要登录
.anyRequest().authenticated()
.and()
//记住我(随机的秘钥(强大的随机字符串)、过期时间)
.rememberMe().userDetailsService(userDetailsService)
.key("your-remember-me-key")
.tokenValiditySeconds(86400)
.and()
.formLogin()
//自定义登录页面
// .loginPage("/index.html").loginProcessingUrl("/login")
.defaultSuccessUrl("/SuccessUrl.html")
//可从默认的login页面登录,并且登录后跳转到main.html
.failureUrl("/SuccessUrl.html");
}
}
自定义登录:
http.loginPage()方法配置登录页,http.loginProcessingUrl()方法定制登录逻辑。登录页的源码DefaultLoginPageGeneratingFilter。
记住我:
登录时提交一个remeber-me的参数,值可以是 on 、yes 、1 、 true,就会记住当前登录用户的token到cookie中。在登出时,会清除记住我功能的cookie。
拦截策略:
antMachers()方法设置路径匹配,可以用两个星号代表多层路径,一个星号代表一个或多个字符,问号代表一个字符。
配置对应的安全策略:
permitAll()所有人都可以访问。
denyAll()所有人都不能访问。
anonymous()只有未登录的人可以访问,已经登录的无法访问。
hasAuthority、hasRole这些是配置需要有对应的权限或者角色才能访问。
AuthenticationManagerBuilder配置认证策略,WebSecurity配置补充的Web请求策略。
csrf:
Cross—Site Request Forgery 跨站点请求伪造。这是一种安全攻击手段,简单来说,就是黑客可以利用存在客户端的信息来伪造成正常客户,进行攻击。例如你访问网站A,登录后,未退出又打开一个tab页访问网站B,这时候网站B就可以利用保存在浏览器中的sessionId伪造成你的身份访问网站A。我们在示例中是使用http.csrf().disable()方法简单的关闭了CSRF检查。而其实Spring Security针对CSRF是有一套专门的检查机制的。他的思想就是在后台的session中加入一个csrf的token值,然后向后端发送请求时,对于GET、HEAD、TRACE、OPTIONS以外的请求,例如POST、PUT、DELETE等,会要求带上这个token值进行比对。当我们打开csrf的检查,再访问默认的登录页时,可以看到在页面的登录form表单中,是有一个name为csrf的隐藏字段的,这个就是csrf的token。例如我们在freemarker的模板语言中可以使用添加这个参数。而在查看Spring Security后台,有一个CsrfFilter专门负责对Csrf参数进行检查。他会调用HttpSessionCsrfTokenRepository生成一个CsrfToken,并将值保存到Session中。
注解级别方法支持 :
在@Configuration支持的注册类上打开注解
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled =true,jsr250Enabled = true
prePostEnabled属性对应@PreAuthorize。
securedEnabled 属性支持@Secured注解,支持角色级别的权限控制。
jsr250Enabled属性对应@RolesAllowed注解,等价于@Secured。
异常处理:
现在前后端分离的状态可以使用@ControllerAdvice
注入一个异常处理类,以@ExceptionHandler注解声明方法,往前端推送异常信息。
6、获取当前用户信息
@Slf4j
@RestController
@RequestMapping("/loginController")
public class LoginController {
@GetMapping("/getLoginUserByPrincipal")
public String getLoginUserByPrincipal(Principal principal) {
return principal.getName();
}
@GetMapping(value = "/getLoginUserByAuthentication")
public String currentUserName(Authentication authentication) {
return authentication.getName();
}
@GetMapping(value = "/username")
public String currentUserNameSimple(HttpServletRequest request) {
Principal principal = request.getUserPrincipal();
return principal.getName();
}
@GetMapping("/getLoginUser")
public String getLoginUser() {
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return user.getUsername();
}
}
五、工作原理
1、结构总结
Spring Security是解决安全访问控制的问题,就是认证和授权。
Spring Security的重点是对所有进入系统的请求进行拦截,校验每个请求是否能够访问所期望的资源,对web资源的保护是通过Filter来实现的。
当初始化Spring Security时,在org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration
中会往spring容器中注入一个SpringSecurityFilterChain
的Servlet过滤器,类型为org.springframework.security.web.FilterChainProxy
,它实现了javax.servlet.Filter
,因此外部的请求都会经过这个类。
而FilterChainProxy是一个代理,真正起作用的是FilterChainProxy中SecurityFilterChain所包含的所有Filter,同时这些Filter都已经注入到Spring容器中。但是他们并不直接处理用户的认证和授权,而是把其交给了认证管理器(AuthenticationManager)和决策管理器(AccessDecisionManager)进行处理。
Spring Security的功能实现主要就是一系列过滤器链相互配合完成的。
SecurityContextPersistenceFilter :
整个拦截过程的入口和出口,在请求开始时从配置好的SecurityContextRepository
中获取SecurityContext
,再设置给SecurityContextHolder
。请求完成后将SecurityContextHolder
持有的SecurityContext
再保存到SecurityContextRepository
,同时清除SecurityContextHolder
所持有的securityContext
。
UsernamePasswordAuthenticationFilter :
用于处理来自表单提交的认证,表单必须提供对应的用户名和密码,内部还有登录成功或失败后进行处理的AuthenticationSuccessHandler
和AuthenticationFailureHandler
。
FilterSecurityInterceptor :
用于保护web资源,使用AccessDecisionManager
对当前用户进行授权访问
ExceptionTranslationFilter :
能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationException
和AccessDeniedException
,其它的异常它会继续抛出
2、认证流程
1)用户提交用户名、密码,SecurityFilterChain中的UsernamePasswordAuthenticationFilter
过滤器获取到,封装为Authentication
。
2)过滤器将Authentication
提交到认证管理器(AuthenticationManager)进行认证。
3)认证成功后,认证管理器(AuthenticationManager)返回一个被填充信息的Authentication
实例。
4)SecurityContextHolder
安全上下文容器将第三步填充了信息的Authentication
,通过SecurityContextHolder.getContext().setAuthentication(…)
赋值到其中。可以看出AuthenticationManager
接口是发起认证的出发点,实现类为ProviderManager
,而Spring Security支持多种认证方式,因此ProviderManager
维护着一个List 列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider
完成的。咱们知道web表单的对应的AuthenticationProvider
实现类为DaoAuthenticationProvider
,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider
将UserDetails填充至Authentication
。
3、授权流程
1、整体流程
1)拦截请求
已认证用户访问受保护的web资源将被SecurityFilterChain中(实现类为DefaultSecurityFilterChain)的 FilterSecurityInterceptor 的子类拦截。
2)获取资源访问策略
FilterSecurityInterceptor会从 SecurityMetadataSource的子类DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需的权限Collection
3)决策
FilterSecurityInterceptor会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则将禁止访问。关于AccessDecisionManager接口,最核心的就是其中的decide方法。这个方法就是用来鉴定当前用户是否有访问对应受保护资源的权限。
2、决策流程
在AccessDecisionManager的实现类ConsensusBased中,是使用投票的方式来确定是否能够访问受保护的资源。
AccessDecisionManager
中包含了一系列的AccessDecisionVoter
讲会被用来对Authentication
是否有权访问受保护对象进行投票,根据投票结果,做出最终角色。
为什么要投票呢?
权限可以从多个方面进行配置,有角色但是没有资源怎么办呢,就需要有不同的处理策略。
AccessDecisionVoter定义了三个方法,赞成、拒绝、弃权。
1)AffirmativeBased 默认:只要有一个投票通过,就表示通过。
- 只要有一个投票通过了,就表示通过。
- 如果全部弃权也表示通过。
- 如果没有人投赞成票,但是有人投反对票,则抛出
AccessDeniedException
.
2)ConsensusBased:多数赞成就通过
- 如果赞成票多于反对票则表示通过
- 如果反对票多于赞成票则抛出AccessDeniedException
- 如果赞成票与反对票相同且不等于0,并且属性
allowIfEqualGrantedDeniedDecisions
的值为true,则表示通过,否则抛出AccessDeniedException
。默认是true。 - 如果所有的
AccessDecisionVoter
都弃权了,则将视参数allowIfAllAbstainDecisions
的值而定,如果该值为true则表示通过,否则将抛出异常AccessDeniedException
。默认为false。
3)UnanimousBased:一票否决。
- 如果受保护对象配置的某一个
ConfigAttribute
被任意的AccessDecisionVoter
反对了,则将抛出AccessDeniedException
。 - 如果没有反对票,但是有赞成票,则表示通过。
- 如果全部弃权了,则将视参数
allowIfAllAbstainDecisions
的值而定,true则通过,false则抛出AccessDeniedException
。Spring Security默认是使用的AffirmativeBased
投票器,我们同样可以通过往Spring容器里注入的方式来选择投票决定器
选择投票器
@Bean
public AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters = Arrays.asList(
new WebExpressionVoter(),
new RoleVoter(),
new AuthenticatedVoter(),
new MinuteBasedVoter()
);
return new UnanimousBased(decisionVoters);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.
//.其他参数
.anyRequest()
.authenticated()
.accessDecisionManager(accessDecisionManager());
}
3、总结
4、自定义认证
1、自定义登录页面及过程
2、数据源改为数据库获取
这两点在上述都有代码及注释展示。
3、配置方法与资源绑定关系
1)代码方式
authenticated() //保护URL,需要用户登录
permitAll() //指定URL无需保护,一般应用与静态资源文件
hasRole(String role) //限制单个角色访问。角色其实相当于一个"ROLE_"+role的资源。
hasAuthority(String authority) //限制单个权限访问
hasAnyRole(String… roles) //允许多个角色访问.
hasAnyAuthority(String… authorities) //允许多个权限访问.
access(String attribute) //该方法使用 SpEL表达式, 所以可以创建复杂的限制.
hasIpAddress(String ipaddressExpression) //限制IP地址或子网
2)注解方式
- 启动类上加入
@EnableGlobalMethodSecurity(securedEnabled=true)
,开启注解过滤权限 - 权限的方法上使用
@Secured(Resource)
,匿名登录IS_AUTHENTICATED_ANONYMOUSLY
@EnableGlobalMethodSecurity(jsr250Enabled=true)
开启@RolesAllowed
注解过滤权限@EnableGlobalMethodSecurity(prePostEnabled=true)
使用表达式时间方法级别的安全性,打开后可以使用以下几个注解。
-
- @PreAuthorize 在方法调用之前,基于表达式的计算结果来限制对方法的访问。例如@PreAuthorize("hasRole('normal') AND hasRole('admin')")
- @PostAuthorize 允许方法调用,但是如果表达式计算结果为false,将抛出一个安全性异常。此注释支持使用returnObject来表示返回的对象。例如@PostAuthorize("returnObject!=null && returnObject.username == authentication.name")
-
- @PostFilter 允许方法调用,但必须按照表达式来过滤方法的结果
- @PreFilter 允许方法调用,但必须在进入方法之前过滤输入值
5、会话控制
1、获取当前用户信息
用户认证通过之后,为了避免每次操作都要进行认证,将用户的信息保存在会话中。SpringSecurity提供了会话管理,认证通过后将身份信息放入SecurityContextHolder上下文,它与当前线程进行绑定,获取用户身份。通过SecurityContextHolder.getContext().getAuthentication()
获取信息。
2、会话控制
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
}
机制 | 描述 |
always | 如果没有session就创建一个 |
ifRequired | 如果需要在登录时创建一个(默认) |
never | 不会创建,但是如果其他应用中创建了session,也会使用 |
stateless | 绝对不创建session,也不会使用 |
3、会话超时
在properties文件可直接配置
server.servlet.session.timeout=3600s
如果会话超时,配置跳转地址
http.sessionManagement()
//session过期
.expiredUrl("/login‐view?error=EXPIRED_SESSION")
//传入的sessionId失效
.invalidSessionUrl("/login‐view?error=INVALID_SESSION");
4、安全会话cookie
在properties文件可直接配置
//true - 浏览器脚本无法访问cookie
server.servlet.session.cookie.http‐only=true
//true - cookie仅通过HTTPS链接发送
server.servlet.session.cookie.secure=true
5、退出
http
.and()
//提供系统退出支持,使用 WebSecurityConfigurerAdapter 会自动被应用
.logout()
//默认退出地址
.logoutUrl("/logout")
//退出后的跳转地址
.logoutSuccessUrl("/login‐view?logout")
//添加一个LogoutHandler,用于实现用户退出时的清理工作.默认 SecurityContextLogoutHandler会被添加为最后一个LogoutHandler
.addLogoutHandler(logoutHandler)
//指定是否在退出时让HttpSession失效,默认是true
.invalidateHttpSession(true);
六、分布式系统认证方案
1、分析
1)统一认证授权
无论不同类型的用户,还是不同类型的客户端,采用一致的认证、授权、会话判断机制,实现统一认证授权服务。
要实现这种统一的认证方式必须可拓展,支持各种认证需求,例如密码、二维码等。
2)多样的认证场景
例如各种支付之间有不同的安全级别,需要对应不同的认证场景。
2)应用接入认证
提供扩展和开放的能力,提供安全的系统对接机制,并且可开放部分API给第三方使用,内部服务和外部第三方服务采用统一的接入机制。
2、分布式认证方案
基于Session和基于Token
1)基于Session
由服务端保存统一的用户信息,只是在分布式环境下,将session信息同步到各个服务,并对请求进行均衡的负载
- session复制
在多台应用服务器之间同步session,并使session保持一致,对外透明。
- session黏贴
当用户访问集群中某台服务器后,强制指定后续所有请求都落到此机器。
- session集中存储
将session存入分布式缓存中,所有服务器应用实例都统一从分布式缓存中获取session信息。
基于session认证的方式,可以更好的在服务端对会话进行控制,并且安全性较高。但是,session机制总体是基于cookie的,客户端需要保存sessionI的,这样在复杂的客户端上不能有效的使用。随着系统的扩展需要提高session的复制、黏贴、存储的容错性。
2)基于Token
服务器不再存储认证数据,易维护,扩展性强,客户端可以把token存在任意地方,并且可以实现web和app统一认证机制。
但是,客户端信息容易泄露,token包含了大量信息,因此一般数据量较大,而且每次请求需要传递,也占宽带。并且token的签名验签操作也会带来负担。
3、选择
通常下,选择token的方式,可以保证整个系统更灵活的拓展性,并且减轻服务端的压力。
在这种情况下,一般会独立出统一认证服务(UAA)和网关两个部分来一起完成认证授权服务。
- 统一认证服务承载接入方认证、登入、授权以及令牌管理,完成实际的用户认证、授权功能。
- 网关会作为整个分布式系统的唯一入口,为接入方提供API结合。本身也具有辅助,例如监控、负载均衡、缓存、协议转换等功能。核心在于,所有的接入方和消费端都通过统一的网关接入微服务,在网关层处理所有与业务无关的功能。
七、Spring Security OAuth2.0
1、依赖
基于上述工程的父依赖,创建子模块security-uaa和security-salary
主要是以下四个:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>
<dependency>
<groupId>javax.interceptor</groupId>
<artifactId>javax.interceptor-api</artifactId>
</dependency>
2、security-uaa
1)配置文件
server.port=1226
spring.application.name=uaa‐service
server.servlet.context‐path=/uaa
2)启动类注解
@SpringBootApplication
@EnableAuthorizationServer
public class SecurityUaaApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityUaaApplication.class, args);
}
}
3)TokenConfig
@Configuration
public class TokenConfig {
//签名key
private static final String SIGN_KEY="uaa";
@Bean
public TokenStore tokenStore(){
//使用基于内存的普通令牌
// return new InMemoryTokenStore();
//基于JWT令牌
// return new JwtTokenStore(accessTokenConvert());
}
// @Bean
// public JwtAccessTokenConverter accessTokenConvert(){
// JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// converter.setSigningKey(SIGN_KEY);
// return converter;
// }
}
4)AuthorizationConfig
@Configuration
public class AuthorizationConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsService clientDetailsService;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private TokenStore tokenStore;
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
//3、配置令牌端点的安全约束
security
//oauth/token_key公开
.tokenKeyAccess("permitAll()")
//oauth/check_token公开
.checkTokenAccess("permitAll()")
//表单认证,申请令牌
.allowFormAuthenticationForClients();
}
@Override
public void configure(ClientDetailsServiceConfigurer clientDetails) throws Exception {
//1、客户端详情服务,用户信息或者数据库配置
clientDetails
//内存方式配置用户信息
.inMemory()
//clientId
.withClient("c1")
//客户端秘钥
.secret(new BCryptPasswordEncoder().encode("secret"))
//资源列表
.resourceIds("salary","mobile")
//该client允许的授权类型
.authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")
//允许的授权范围
.scopes("all")
//跳转到授权界面
.autoApprove(false)
//回调地址
.redirectUris("https://www.baidu.com");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//2、配置令牌的访问端点和令牌服务
endpoints
//定制授权同意页面
.pathMapping("/oauth/confirm_access", "/custom/confirm_access")
//认证管理器
.authenticationManager(authenticationManager)
//密码模式的用户信息管理
.userDetailsService(userDetailsService)
//授权码服务
.authorizationCodeServices(authorizationCodeServices())
//令牌管理服务
.tokenServices(tokenService())
//允许请求方式
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}
/**
* token配置
*
* @return
*/
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
//客户端详情服务
service.setClientDetailsService(clientDetailsService);
//允许令牌自动刷新
service.setSupportRefreshToken(true);
//令牌存储策略-内存
service.setTokenStore(tokenStore);
//使用jtw
service.setTokenEnhancer(jwtAccessTokenConverter);
//令牌默认有效期2小时
service.setAccessTokenValiditySeconds(7200);
//刷新令牌默认有效期3天
service.setRefreshTokenValiditySeconds(259200);
return service;
}
/**
* 设置授权码模式的授权码如何存取,暂时用内存方式。
*
* @return
*/
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new InMemoryAuthorizationCodeServices();
}
}
核心步骤:
1)ClientDetailsServiceConfigurer
:用来配置客户端详情服务(ClientDetailsService),客户端详情信息在这里进行初始化。能够使用内存或者JDBC来实现客户端详情服务(ClientDetailsService),负责查找ClientDetails,一个ClientDetails代表一个需要接入的第三方应用。
- clientId: 用来标识客户的ID
- secret: 客户端安全码
- scope: 用来限制客户端的访问范围,如果是空(默认)的话,那么客户端拥有全部的访问范围。
- authrizedGrantTypes:客户端可以使用的授权类型,默认为空。
- authorities:客户端可以使用的权限
- redirectUris:回调地址
2)AuthorizationServerEndpointsConfifigurer
:用来配置令牌(token)的访问端点和令牌服务(tokenservices)。
令牌服务:
实现一个AuthorizationServerTokenServices这个接口,需要继承DefaultTokenServices这个类。可以使用它来修改令牌的格式和令牌的存储。默认情况下在创建一个令牌时,是使用随机值来进行填充的。这个类中完成了令牌管理的几乎所有的事情,唯一需要依赖的是spring容器中的一个TokenStore接口实现类来定制令牌持久化。
- InMemoryTokenStore:默认方式。适合在单服务器上运行(即并发访问压力不大的情况下,并且他在失败时不会进行备份)。大多数的项目都可以使用这个实现类来进行尝试。也可以在并发的时候来进行管理,因为不会被保存到磁盘中,所以更易于调试。
- JdbcTokenStore:基于JDBC的实现类,令牌会被保存到关系型数据库中。使用这个实现类,可以在不同的服务器之间共享令牌信息。类似还有RedisTokenStore基于Redis存储令牌信息。
- JwtTokenStore(JSON Web Token):把令牌信息全部编码整合进令牌本身,这样后端服务可以不用存储令牌相关信息。缺点, 那就是撤销一个已经授权的令牌非常困难。通常用来处理一个生命周期较短的令牌以及撤销刷新令牌(refresh_token)。并且令牌会比较大,因为他要包含较多的用户凭证信息。
访问端点:
配置授权类型(Grant Types)
- authenticationManager:认证管理器。当选择password(资源所有者密码)这个授权类型时,就需要指定authenticationManager对象来进行鉴权。
- userDetailsService:用户主体管理服务。如果设置这个属性,说明有一个自定义的UserDetailsService接口的实现,或者你可以设置到全局域(例如GlobalAuthenticationManagerConfigurer)上去,当设置后,那么refresh_token刷新令牌方式的授权类型流程中就会多包含一个检查步骤,来确保这个账号是否仍然有效。
- authorizationCodeServices:用来设置授权服务器的,主要用于authorization_code 授权码类型模式。
- implicitGrantService:用于设置隐式授权模式的状态。
- tokenGranter:如果设置了这个东东(即TokenGranter接口的实现类),那么授权将会全部由自己掌控,并且会忽略掉以上几个属性。这个属性一般是用作深度拓展用途。
配置授权断点的URL(Endpoint URLS)
- 可以通过pathMapping()方法来配置断点URL的链接地址。即将oauth默认的连接地址替代成其他的URL链接地址。例如spring security默认的授权同意页面/auth/confirm_access,就可以通过passMapping()方法映射成自己定义的授权同意页面。
3)AuthorizationServerSecurityConfigurer
:用来配置令牌端点的安全约束.
5)web安全配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 从父类加载认证管理器
*
* @return
* @throws Exception
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager userDetailsManager =
new InMemoryUserDetailsManager(User.withUsername("admin").password(passwordEncoder().encode("admin")).authorities("mobile", "salary").build()
, User.withUsername("manager").password(passwordEncoder().encode("manager")).authorities("salary").build()
, User.withUsername("worker").password(passwordEncoder().encode("worker")).authorities("worker").build());
return userDetailsManager;
}
/**
* 配置用户的安全拦截策略
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//链式配置拦截策略
http.csrf().disable()//关闭csrf跨域检查
.authorizeRequests()
.anyRequest().authenticated() //其他请求需要登录
.and() //并行条件
.formLogin(); //可从默认的login页面登录,并且登录后跳转到
}
}
6)测试
-
客户端模式
客户端向授权服务器发送自己的身份信息,请求令牌access_token,直接返回
-
密码模式
-
简化模式:
http://localhost:1226/uaa/oauth/authorize?client_id=c1&response_type=token&scope=all&redirect_uri=https://www.baidu.com
配置自己的端口号,请求路径,client_id、type、scope、uri,会自动跳转到请求登录界面,选择approve后,跳转到baidu
-
授权码模式
http://localhost:1226/uaa/oauth/authorize?client_id=c1&response_type=code&scope=all&redirect_uri=http://www.baidu.com
跳转到baidu,拿到code,再请求
-
验证令牌
3、security-salary
1)注解
@EnableResourceServer
@SpringBootApplication
public class SecuritySalaryApplication {
public static void main(String[] args) {
SpringApplication.run(SecuritySalaryApplication.class, args);
}
}
2)ResourceServerConfig
@Configuration
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
public static final String RESOURCE_SALARY = "salary";
//使用JWT令牌,需要引入与uaa一致的tokenStore,存储策略
@Autowired
private TokenStore tokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
//资源ID
.resourceId(RESOURCE_SALARY)
//使用远程服务验证令牌的服务
// .tokenServices(tokenServices())
//使用jwt令牌
.tokenStore(tokenStore)
//无状态模式
.stateless(true);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
//校验请求
.authorizeRequests()
// 路径匹配规则
.antMatchers("/salary/**")
// 需要匹配scope
.access("#oauth2.hasScope('all')")
.and().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
;
}
/**
* 配置access_token远程验证策略
*
* @return
*/
public ResourceServerTokenServices tokenServices() {
// DefaultTokenServices services = new DefaultTokenServices();
RemoteTokenServices services = new RemoteTokenServices();
services.setCheckTokenEndpointUrl("http://localhost:1226/uaa/oauth/check_token");
services.setClientId("c1");
services.setClientSecret("secret");
return services;
}
}
public ResourceServerTokenServices tokenServices()
如果资源服务和授权服务是在同一个应用程序上,那可以使用DefaultTokenServices,这样的话,就不用考虑关于实现所有必要的接口一致性的问题。而如果资源服务器是分离的,那就必须要保证能够有匹配授权服务提供的ResourceServerTokenServices,知道如何对令牌进行解码。
3)TokenConfig
@Configuration
public class TokenConfig {
//签名key
private static final String SIGN_KEY="uaa";
@Bean
public TokenStore tokenStore(){
//使用基于内存的普通令牌
// return new InMemoryTokenStore();
//基于JWT令牌
return new JwtTokenStore(accessTokenConvert());
}
@Bean
public JwtAccessTokenConverter accessTokenConvert(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGN_KEY);
return converter;
}
}
4)WebSecurityConfig
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/salary/**")
.hasAuthority("salary")
.anyRequest().permitAll();
}
}
5)SalaryController
@RestController
@RequestMapping("/salary")
public class SalaryController {
@GetMapping("/query")
public String query() {
return "salary";
}
}
6)测试
八、JWT令牌
一个开放的行业标准(RFC 7519),它定义了一种简单的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名,可以被验证和信任。JWT可以使用HMAC算法或使用RSA算法的公私钥来签名,方式被篡改。
JWT令牌的优点:
基于json,非常方便解析
可以在令牌中自定义内容,易扩展
通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高
资源服务使用JWT可以不依赖于认证服务,自己完成解析
缺点:
令牌较长,占据的存储空间比较大
组成:
- Header
头部包括令牌的类型(JWT)以及使用的哈希算法(如HMAC SHA256 RSA)
- Payload
负载,内容也是一个对象,存放有效信息的地方,可以存放JWT提供的现有字段,例如 iss(签发者),exp(过期时间戳),sub(面向的用户)等,也可以自定义字段。不建议存放敏感信息,因为可以解码还原出原始内容。最后将这部分JSON内容使用Base64URL编码,就得到了JWT令牌的第二个部分。
- Signature
签名,用于防止JWT内容被篡改。这个部分使用Base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明的签名算法进行签名。
代码在上一个部分展示。