简介
说明
- 开启日志(yml配置):
logging.level.org.springframework.security: DEBUG
- 主要类
- 配置接口
WebSecurityConfigurer
;结合springmvc,可继承WebSecurityConfigurerAdapter
- 认证时(登录)需要提供
AuthenticationProvider
,默认是AbstractUserDetailsAuthenticationProvider
进行登录判断 - 认证逻辑管理接口
AuthenticationManager
- 配置接口
- spring security实现方法 ^1
- 不用数据库,全部数据写在配置文件,这个也是官方文档里面的demo
- 使用数据库,根据spring security默认实现代码设计数据库,也就是说数据库已经固定了,这种方法不灵活,而且那个数据库设计得很简陋,实用性差
- spring security和Acegi不同,它不能修改默认filter了,但支持插入filter,所以根据这个,我们可以插入自己的filter来灵活使用 (可基于此数据库结构进行自定义参数认证)
- 暴力手段,修改源码,前面说的修改默认filter只是修改配置文件以替换filter而已,这种是直接改了里面的源码,但是这种不符合OO设计原则,而且不实际,不可用
- 如果使用内存中用户,默认会创建一个user用户,密码自动生成并打印日志如
Using generated security password: 126ad028-4bd5-4b8b-a8d3-d4d4b6b716ed
注意
- spring-security登录只能接受
x-www-form-urlencoded
(简单键值对)类型的数据,form-data
(表单类型,可以含有文件)类型的请求获取不到参数值 axios
实现x-www-form-urlencoded
请求:参数应该写到param
中。如果写在data
中则不行,加headers: {'Content-Type': 'application/x-www-form-urlencoded'}
也不行server.tomcat.use-relative-redirects=true
对于复杂的网络环境,如前置网关可能会导致前端重定向到内网地址,此时设置此参数,从而sendRedirect重定向时写入的Header Location响应头为相对路径
springboot整合
引入依赖
1
2
3
4
5<!-- Spring-Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
示例
SpringSecurityConfig 访问权限规则设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
private AccessDeniedHandler accessDeniedHandler;
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 如果使用内存中用户,默认会创建一个user用户,密码自动生成并打印日志如`Using generated security password: 126ad028-4bd5-4b8b-a8d3-d4d4b6b716ed`
auth.inMemoryAuthentication()
.withUser("admin").password("admin").roles("ADMIN") // 在内存中定义用户名密码为admin/admin, 角色为ADMIN的用户(用于登录和权限判断)
.and()
.withUser("user").password("user").roles("USER");
}
// 定义权限规则
protected void configure(HttpSecurity http) throws Exception {
http.headers().frameOptions().disable(); // 解决spring boot项目中出现不能加载iframe
http.csrf().disable() // 关闭打开的csrf(跨站请求伪造)保护
.authorizeRequests()
.antMatchers("/manage/", "/manage/home", "/manage/about", "/manage/404", "/manage/403", "/thymeleaf/**").permitAll() // 这些端点不进行权限验证
.antMatchers("/res/**").permitAll() // idea的resources/static目录下的文件夹对应一个端点,相当于可以访问resources/static/res/下所有文件(还有一些默认的端点:/css/**、/js/**、/images/**、/webjars/**、/**/favicon.ico)
.antMatchers("/manage/**").hasAnyRole("ADMIN") // 需要有ADMIN角色才可访问/admin
.antMatchers("/user/**").hasAnyRole("USER", "ADMIN") // 有USER/ADMIN角色均可
.anyRequest().authenticated() // (除上述忽略请求)所有的请求都需要权限认证
.and()
.formLogin()
.loginPage("/manage/login").permitAll() // 登录界面(Get)和登录处理方法(Post。具体逻辑不需要写,并且会自动生成此端点的control). 登录成功后,如果从登录界面登录则跳到项目主页(http://localhost:9526),如果从其他页面跳转到登录页面进行登录则成功后跳转到原始页面
.and()
.logout().permitAll() // 默认访问/logout(Get)即可登出
.and()
.exceptionHandling()
// @PreAuthorize 注解抛出AccessDeniedException异常,不会被accessDeniedHandler捕获,而是会被全局异常捕获; 只能捕获上文 hasAnyRole 等验证
.accessDeniedHandler(accessDeniedHandler);
}
}AccessDeniedHandler访问受限拦截
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SmAccessDeniedHandler implements AccessDeniedHandler {
private static Logger logger = LoggerFactory.getLogger(SmAccessDeniedHandler.class);
public void handle(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
AccessDeniedException e) throws IOException, ServletException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
logger.info("用户 '" + auth.getName() + "' 试图访问受保护的 URL: " + httpServletRequest.getRequestURI());
}
System.out.println("auth = " + auth);
httpServletResponse.sendRedirect("/manage/403"); // 跳转到403页面
}
}
示例扩展
- 此示例使用数据库用户名/密码(或扩展验证)进行用户登录验证,并且对登录成功做处理,资源权限控制
SpringSecurityConfig 访问权限规则设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83true) // 开启方法级别权限控制 (prePostEnabled=
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
public static final String Login_Uri = "/manage/login";
private CustomAuthenticationProvider authProvider; // 提供认证算法(判断是否登录成功)(1)
private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource; // 认证信息
private AuthenticationSuccessHandler authenticationSuccessHandler; // 用于处理登录成功(2)
private AuthenticationFailureHandler authenticationFailureHandler; // 用于处理登录失败(2)
private AccessDeniedHandler accessDeniedHandler; // 用于处理无权访问 (3)
private JwtAuthenticationFilter jwtAuthenticationFilter; // 用于基于token的验证,如果基于session的则可去掉 (4)
// spring security 4配置认证器
// @Autowired
// public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// auth.authenticationProvider(authProvider);
// }
// 定义权限规则
protected void configure(HttpSecurity http) throws Exception {
// 用于基于token的验证,如果基于session的则可去掉 (4)
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 所有的请求都会先走此拦截器
http.headers().frameOptions().disable(); // 解决spring boot项目中出现不能加载iframe
http.csrf().disable() // 关闭打开的csrf(跨站请求伪造)保护
.authorizeRequests()
.antMatchers("/favicon.ico", "/manage/", "/manage/index", "/manage/404", "/manage/403", "/thymeleaf/**").permitAll() // 这些端点不进行权限验证
.antMatchers("/res/**").permitAll() // idea的resources/static目录下的文件夹对应一个端点,相当于可以访问resources/static/res/下所有文件(还有一些默认的端点:/css/**、/js/**、/images/**、/webjars/**、/**/favicon.ico)
.antMatchers("/manage/**").hasAnyRole("ADMIN") // 需要有ADMIN角色才可访问/admin(有先后顺序,前面先定义的优先级高,因此比antMatchers("/**").hasAnyRole("USER", "ADMIN")优先级高)
.antMatchers("/**").hasAnyRole("USER", "ADMIN") // 有USER/ADMIN角色均可
.anyRequest().authenticated() // (除上述忽略请求)所有的请求都需要权限认证
.and()
.authenticationProvider(authProvider) // spring security 5设置认证器
.formLogin()
.loginPage(Login_Uri).permitAll() // 登录界面(Get)
// 或者通配符/**/login拦截对"/manage/login"和"/login"等的POST请求(登录请求。具体逻辑不需要写,并且会自动生成此端点的control。不写则和loginPage端点一致). 不包含server.servlet.context-path的路径
// .loginProcessingUrl(Login_Uri)
.successHandler(authenticationSuccessHandler) // 此处定义登录成功处理方法
.failureHandler(authenticationFailureHandler)
.authenticationDetailsSource(authenticationDetailsSource)
.and()
.logout().logoutUrl("/manage/logout").logoutSuccessUrl(Login_Uri).permitAll() // 访问"/manage/logout"登出,登出成功后跳转到"/manage/login"
.and()
.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler)
// 默认未登录的请求会重定向到登录页面。如果项目仅提供API时,需直接返回错误数据
.authenticationEntryPoint((request, response, e) -> {
BaseController.writeError(response, "尚未认证");
});
}
// 密码加密器 (5)
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// spring security 4.1.1.RELEASE 中提供的 SaltSource
// 加密混淆器
public SaltSource saltSource() {
return new CustomSaltSource();
}
// 混淆器实现
private class CustomSaltSource implements SaltSource {
public Object getSalt(UserDetails userDetails) {
return "aezocn";
}
}
}自定义登录认证字段(spring security默认基于username/password完成)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
private static final long serialVersionUID = 1L;
private final String wxCode; // 此处为微信公众号使用微信code进行认证,也可扩展邮箱/手机号等
public CustomWebAuthenticationDetails(HttpServletRequest request) {
super(request);
wxCode = request.getParameter("wxCode");
}
public String getWxCode() {
return wxCode;
}
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(super.toString()).append("; wxCode: ").append(this.getWxCode());
return sb.toString();
}
}将自定义登录认证字段加入到认证数据源
1
2
3
4
5
6
7
8
public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
return new CustomWebAuthenticationDetails(context);
}
}根据用户唯一字段(如username、wxCode)获取用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83// principal 如用户信息(UserDetailsService),credentials 如凭证密码信息
public class CustomUserDetailsService implements UserDetailsService {
private final UserDao userDao;
public CustomUserDetailsService(UserDao userDao) {
this.userDao = userDao;
}
// 根据自定义登录认证字段获取用户信息。此处简化微信公众号认证(原本需要先拿到openid)
public UserDetails loadUserByWxCode(String wxCode)
throws UsernameNotFoundException {
if(wxCode == null || "".equals(wxCode)) {
throw new UsernameNotFoundException("invalid wxCode " + wxCode);
}
User user = userDao.findByWxCode(wxCode);
if(user == null) {
throw new UsernameNotFoundException("Could not find user, user wxCode " + wxCode);
}
return new CustomUserDetails(user);
}
// 默认根据username(唯一)获取用户信息
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
if(username == null || "".equals(username)) {
throw new UsernameNotFoundException("invalid username " + username);
}
User user = userDao.findByUsername(username);
if(user == null) {
throw new UsernameNotFoundException("Could not find user " + username);
}
return new CustomUserDetails(user);
}
/**
* 自定义用户认证Model。此处的User为开发者自定义的User(非Spring Security内置User)
*/
private final static class CustomUserDetails extends User implements UserDetails {
private CustomUserDetails(User user) {
// 初始化父类,需要父类有User(User user){...}的构造方法
super(user); // BeanUtils.copyProperties(user, this);
// 或者在此处初始化
// this.setUsername(user.getUsername());
// this.setPassword(user.getPassword());
// ...
}
public Collection<? extends GrantedAuthority> getAuthorities() {
// 组成如:ROLE_ADMIN/ROLE_USER,在资源权限定义时写法如:hasRole('ADMIN')。createAuthorityList接受一个数组,说明支持一个用户拥有多个角色
// 此处使用直接在User表中加了一个字段roleCode,实际项目中可以新建一个 user_role 和 role_permission 表,此处去权限的code即可(用户和角色多对多,角色和权限多对多)
return AuthorityUtils.createAuthorityList("ROLE_" + this.getRoleCode());
}
public boolean isAccountNonExpired() {
return true;
}
public boolean isAccountNonLocked() {
return true;
}
public boolean isCredentialsNonExpired() {
return true;
}
public boolean isEnabled() {
return true;
}
private static final long serialVersionUID = 5639683223516504866L;
}
}(1) 基于自定义登录认证字段,提供登录算法(返回认证对象Authentication)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class CustomAuthenticationProvider implements AuthenticationProvider {
private CustomUserDetailsService customUserDetailsService;
private PasswordEncoder passwordEncoder;
public CustomAuthenticationProvider() {
super();
}
public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
final String wxCode = details.getWxCode();
final String username = authentication.getName();
final String password = authentication.getCredentials().toString();
UserDetails userDetails = null;
if(!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
userDetails = customUserDetailsService.loadUserByUsername(username);
// 验证密码
if(userDetails == null || userDetails.getPassword() == null) {
throw new BadCredentialsException("invalid password");
}
if(!passwordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException("wrong password");
}
} else if(!StringUtils.isEmpty(wxCode)) {
userDetails = customUserDetailsService.loadUserByWxCode(wxCode);
} else {
throw new BadCredentialsException("invalid params: username,password and wxCode are invalid");
}
if(userDetails != null) {
// 授权
final List<GrantedAuthority> grantedAuths = (List<GrantedAuthority>) userDetails.getAuthorities();
final Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, password, grantedAuths);
return auth;
}
return null;
}
public boolean supports(final Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}- 上述抛出异常AuthenticationException会被下面的MyAuthenticationFailureHandler类捕获。提供的AuthenticationException有:
UsernameNotFoundException
用户找不到BadCredentialsException
无效的凭据AccountStatusException
用户状态异常它包含如下子类AccountExpiredException
账户过期LockedException
账户锁定DisabledException
账户不可用CredentialsExpiredException
证书过期
- 上述抛出异常AuthenticationException会被下面的MyAuthenticationFailureHandler类捕获。提供的AuthenticationException有:
(2) 登录校验完成拦截:登录成功/失败处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class LoginFinishHandler {
private Logger logger = LoggerFactory.getLogger(LoginFinishHandler.class);
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
String wxCode = details.getWxCode();
HttpSession session = httpServletRequest.getSession();
User user = (User) authentication.getPrincipal();
session.setAttribute("SESSION_USER_INFO", user);
logger.info("{} 登录成功", user.getUsername());
httpServletResponse.sendRedirect("/manage/403");
//BaseController.writeSuccess(httpServletResponse, "登录成功", MiscU.Instance.toMap(
// BaseKeys.AccessToken, accessToken,
// BaseKeys.RefreshToken, refreshToken,
// "user_id", userDetails.getUserId(),
// "username", userDetails.getUsername(),
// "role_codes", userDetails.getRoleCodes(),
//));
}
}
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
logger.info("登录失败:" + e.getMessage());
httpServletResponse.sendRedirect("/manage/login");
// BaseController.writeError(httpServletResponse, e.getMessage());
}
}
}(3) AccessDeniedHandler访问受限拦截同上例
(4) token验证(基于session的验证可以不加此拦截器,基于无状态的Restful则需要拦截token并解析获得用户名和相关权限。配置文件加
security.sessions=stateless
时spring security才不会使用session)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private String token_header = "X-Token";
private SecurityJwtTokenUtils securityJwtTokenUtils; // 基于JWT的工具类:用于生成和解析JWT机制的token
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// TODO
if(!SpringSecurityConfig.Login_Uri.equals(request.getRequestURI())) {
String authToken = request.getHeader(this.token_header);
if(StringUtils.isEmpty(authToken)) {
// throw new ExceptionU.AuthTokenInvalidException(); // 这样会导致SpringSecurity公开路径无法访问。此时不进行获取认证对象,由后面拦截访问私有路径的
chain.doFilter(request, response);
return;
}
try {
String username = securityJwtTokenUtils.getUsernameFromToken(authToken);
if(username == null)
throw new ExceptionU.AuthTokenInvalidException();
logger.info(String.format("Checking authentication for user %s.", username));
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// It is not compelling necessary to load the use details from the database. You could also store the information
// in the token and read it from it. It's up to you ;)
// UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
UserDetails userDetails = securityJwtTokenUtils.getUserFromToken(authToken);
// For simple validation it is completely sufficient to just check the token integrity. You don't have to call
// the database compellingly. Again it's up to you ;)
if (securityJwtTokenUtils.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
logger.info(String.format("Authenticated user %s, setting security context", username));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (SignatureException e) {
throw new ExceptionU.AuthTokenInvalidException();
}
}
chain.doFilter(request, response);
}
}(5) 密码保存
1
2
3
4
5
6
7
8// PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(16);
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 保存密码(smalle):$2a$10$j5daLww7/c4Qdj1U30Djt.Mzh0pDdYtOrlJ3zQ91u4IC/no2bcViG
String password = passwordEncoder.encode("smalle");
System.out.println("password = " + password);
Assert.assertTrue(passwordEncoder.matches("smalle", password));
在方法(资源)上加权限控制
- Spring Security中定义了四个支持使用表达式的注解,分别是
@PreAuthorize
、@PostAuthorize
、@PreFilter
和@PostFilter
- 其中前两者可以用来在方法调用前或者调用后进行权限检查,后两者可以用来对集合类型的参数或者返回值进行过滤
- 注解可作用于Controller、Service、Mapper
- 支持SPEL表达式
- 前提是需要开启
@EnableGlobalMethodSecurity(prePostEnabled=true)
配置,标识开启方法级别权限控制
- 更多权限控制说明:https://docs.spring.io/spring-security/site/docs/4.2.3.RELEASE/reference/htmlsingle/#el-common-built-in,相关方法参考:SecurityExpressionRoot
- 配置
1 |
|
使用 ^7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81// ======= 示例1
// Controller.java
// @PreAuthorize("hasRole('ADMIN')") // 可使用自定义注解@HasAdminRole进行封装(可组合更复杂的权限注解)。一般对应权限
// @PreAuthorize("hasRole('ADMIN') or hasRole('SUPER')")
"/adminRole") (
public String adminRole() {
return "/adminRole";
}
// HasAdminRole.java 自定义权限注解,被@HasAdminRole注解的方法需要有ADMIN角色
(RetentionPolicy.RUNTIME)
"hasRole('ADMIN')") (
public HasAdminRole {
}
// ======= 示例2
// 可以在表达式中使用方法参数
"#id<10") // 限制只能查询Id小于10的用户 (
public User find(int id) {
System.out.println("id=" + id);
return null;
}
"principal.username.equals(#username)") // 限制只能查询自己的信息,principal为内置属性 (
public User find(String username) {
System.out.println("username=" + username);
return null;
}
"#user.name.equals('abc')") // 限制只能新增用户名称为abc的用户 (
// public void add(@P("user") User user) { // 或者使用 @P 或 @Param 注解值
public void add(User user) {
System.out.println("user=" + user);
}
// ======= 示例3
// returnObject为内置返回对象名。@PostAuthorize是在方法调用完成后进行权限检查,它不能控制方法是否能被调用,只能在方法调用完成后检查权限决定是否要抛出AccessDeniedException,此异常不会被accessDeniedHandler捕获,而是会被全局异常捕获
"returnObject.id % 2 == 0") (
public User find(int id) {
User user = new User();
user.setId(id);
return user;
}
// ======= 示例4
// filterObject为内置对象名。使用@PreFilter和@PostFilter时,Spring Security将移除使对应表达式的结果为false的元素。仅能用于返回类型为集合等类型,否则报错:IllegalArgumentException: Filter target must be a collection, array, or stream type, but was Result(status=success, message=null, ...
"ids", value="filterObject % 2 == 0") // filterTarget属性指定基于过滤的传参数名 (filterTarget=
public void delete(List<Integer> ids, List<String> usernames) {
// ...
}
"filterObject.id % 2 == 0") // 将对返回结果中id不为偶数的user进行移除 (
public List<User> findAll() {
List<User> userList = new ArrayList<User>();
User user;
for (int i=0; i<10; i++) {
user = new User();
user.setId(i);
userList.add(user);
}
return userList;
}
"filterObject.code != 'super'") // 作用于Mapper亦可 (
List<RoleVo> selectRolePage(IPage page, RoleVo role);
// 示例5:引用Beans
"@ws.check(request)") // ws引用下文Bean判断 (
"ws") (
public class WebSecurity {
public boolean check(HttpServletRequest request) {
// 返回true表示有权限,false无权限
}
}
// ======= 示例6:hasPermission表达式(建议使用引用Bean代替)。具体参考:https://www.baeldung.com/spring-security-create-new-custom-security-expression
// @PostAuthorize("hasAuthority('foo_read')")
"hasPermission(returnObject, 'read')") // 返回对象类型为 Foo (
"hasPermission(#id, 'Foo', 'read')") // #id为方法参数,Foo为类型 (
// 示例7:自定义根表达式
"hasRoleCode('super') or not hasRoleCode('super') and filterObject.code != 'super'") // 自定义方法 hasRoleCode,可基于 SecurityExpressionRoot 和 DefaultMethodSecurityExpressionHandler 实现,具体参考:https://www.baeldung.com/spring-security-create-new-custom-security-expression (
List<RoleVo> selectRolePage(IPage page, RoleVo role);
CSRF/CORS
CSRF
跨站请求伪造(Cross-Site Request Forgery). csrfCORS
跨站资源共享(Cross Origin Resourse-Sharing).开启cosr:使用spring security时,需要同时在spring mvc 和 spring security中配置CORS。cors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(); // 开启cors需要关闭csrf
http.cors();
// ...
}
// 配置cors
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
spring-security-oauth2
- 理解Oauth 2.0-阮一峰 ^2
- Oauth与SSO
- OAuth是为解决不同公司的不同产品实现登陆的一种简便授权方案。通常这些授权服务都是由大公司提供的,如QQ,新浪微博,人人网等。使用OAuth授权的好处是,在为用户提供某些服务时,可减少或避免因用户懒于注册而导致的用户流失问题
- SSO通常处理的是一个公司的不同应用间的访问登陆问题。如企业应用有很多业务子系统,只需登陆一个系统,就可以实现不同子系统间的跳转,而避免了登陆操作
- A/B应用仅仅使用同一套JWT规则是否无法实现单点登录。即如果A/B单独通过浏览器访问,未登录的系统存在无法自动获取token的问题;如果将A/B嵌入到C,浏览器中访问C可实现统一认证
- 单点登陆需要浏览器可同时访问A/B,只需要登陆一次
- OAuth与SSO的应用场景不同,虽然可以使用OAuth实现SSO,但并不建议这么做。不过,如果SSO和OAuth结合起来的话,理论上是可以打通各个公司的各个不同应用间的登陆问题,但现实往往是残酷的
- SSO相关文章
- Oauth2根据使用场景不同,分成了4种模式
- 授权码模式(authorization code):授权码模式使用到了回调地址,是最为复杂的方式,通常网站中经常出现的微博,qq第三方登录,都会采用这个形式
- 用户访问客户端,后者将前者导向认证服务器
- 用户选择是否给予客户端授权
- 假设用户给予授权,认证服务器将用户导向客户端事先指定的”重定向URI”(redirection URI),同时附上一个授权码
- 客户端收到授权码,附上早先的”重定向URI”,向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见
- 认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)
- 简化模式(implicit):不常用
- 密码模式(resource owner password credentials):在这种模式中,用户必须把自己的密码(认证服务器的用户)给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分。在认证时客户端需要使用用户提供的用户名、密码,以及客户端的client_id,client_secret向认证服务器请求。此时返回的access_token所包含的权限是用户本身的权限,而不是客户端的权限
- 用户向客户端提供用户名和密码
- 客户端将用户名和密码发给认证服务器,向后者请求令牌
- 认证服务器确认无误后,向客户端提供访问令牌
- 客户端模式(client credentials):client模式,没有用户的概念,直接与认证服务器交互,用配置中的客户端信息去申请access_token,客户端有自己的client_id,client_secret对应于用户的username,password,而客户端也拥有自己的authorities,当采取client模式认证时,对应的权限也就是客户端自己的authorities
- 客户端向认证服务器进行身份认证,并要求一个访问令牌
- 认证服务器确认无误后,向客户端提供访问令牌
- 授权码模式(authorization code):授权码模式使用到了回调地址,是最为复杂的方式,通常网站中经常出现的微博,qq第三方登录,都会采用这个形式
- 相关角色划分
- 资源(如:用户信息)
- 资源所有者(最终用户,拥有个人用户信息的人)
- 用户代理(如:浏览器)
- 授权服务器
- 资源服务器(无需在认证服务器上注册。如:服务商托管用户信息)
- 要访问资源服务器受保护的资源需要携带令牌(从授权服务器获得)
- 客户端往往同时也是一个资源服务器,各个服务之间的通信(访问需要权限的资源)时需携带访问令牌
- 资源服务器通过
@EnableResourceServer
注解来开启一个OAuth2AuthenticationProcessingFilter
类型的过滤器 - 通过继承
ResourceServerConfigurerAdapter
类来配置资源服务器
- 客户端(需要在认证服务器上注册。如:第三方应用程序)
- 可自行编写登录逻辑(获取令牌->获取用户信息)
- 也可使用 OAuth2 提供的
@EnableOAuth2Sso
注解实现单点登录,该注解会添加身份验证过滤器替我们完成所有操作,只需在配置文件里添加授权服务器和资源服务器的配置即可
- spring cloud结合oauth2网关角色 ^4
@EnableResourceServer
网关充当资源服务器拦截请求,下游服务无需开启oauth验证(网关不对认证服务器相关端点验证)。弊端:资源服务器某些端点无需认证则需要统一在网关处配置@EnableOAuth2Sso
网关充当客户端,下游服务也以客户端或资源服务器进行认证。(单点登录必须保证客户端和授权服务器的hostname不同或者SESSIONID名称不同)
- 授权服务器
- 一些默认的端点URL(TokenEndpoint、AuthorizationEndpoint)
/oauth/authorize
授权端点/oauth/token
令牌端点/oauth/confirm_access
用户确认授权提交端点/oauth/error
授权服务错误信息端点/oauth/check_token
用于资源服务访问的令牌解析端点/oauth/token_key
提供公有密匙的端点,如果你使用JWT令牌的话
- 授权类型(Grant Types):授权是使用 AuthorizationEndpoint 这个端点来进行控制的,使用
AuthorizationServerEndpointsConfigurer
这个对象实例来进行配置,默认是支持除了密码授权外所有标准授权类型,它可配置以下属性- authenticationManager:认证管理器,当你选择了资源所有者密码(password)授权类型的时候,请设置这个属性注入一个 AuthenticationManager 对象
- userDetailsService:可定义自己的 UserDetailsService 接口实现
- authorizationCodeServices:用来设置收取码服务的(即 AuthorizationCodeServices 的实例对象),主要用于 “authorization_code” 授权码类型模式
- implicitGrantService:这个属性用于设置隐式授权模式,用来管理隐式授权模式的状态
- tokenGranter:完全自定义授权服务实现(TokenGranter 接口实现),只有当标准的四种授权模式已无法满足需求时
- 使用jwt令牌
- 使用 JWT 令牌需要在授权服务中使用
JWTTokenStore
,资源服务器也需要一个解码 Token 令牌的类JwtAccessTokenConverter
,JwtTokenStore 依赖这个类进行编码以及解码,因此授权服务以及资源服务都需要配置这个转换类 - Token 令牌默认是有签名的,并且资源服务器中需要验证这个签名,因此需要一个对称的 Key 值,用来参与签名计算。这个 Key 值存在于授权服务和资源服务之中,或者使用非对称加密算法加密 Token 进行签名,Public Key 公布在 /oauth/token_key 这个 URL 中
- 默认 /oauth/token_key 的访问安全规则是 “denyAll()” 即关闭的,可以注入一个标准的 SpingEL 表达式到 AuthorizationServerSecurityConfigurer 配置类中将它开启,例如 permitAll()
- 需要引入 spring-security-jwt 库
- 使用 JWT 令牌需要在授权服务中使用
- 一些默认的端点URL(TokenEndpoint、AuthorizationEndpoint)
- access_token获取(BearerTokenExtractor#extractToken)
- 默认从header中获取,传入方式如:
Authorization: Bearer my_access_token_888
(POST时使用) - header中获取不到则通过
request.getParameter("access_token")
获取
- 默认从header中获取,传入方式如:
客户端模式和密码模式
源码参考 spring-security-oauth2 -> oauth2-client-password
- 依赖
1 | <!-- 不是starter,手动配置 --> |
- token存储在redis中时增加配置
1 | # token保存在redis中需要开启 |
- SpringSecurity Web配置
1 |
|
- 认证服务器和资源服务器配置(认证服务器基于Spring Security验证用户名/密码的配置省略)
1 |
|
- 资源
@GetMapping("/product/{id}")
和@GetMapping("/order/{id}")
的定义省略 - 访问
1 | // 客户端模式(client credentials) |
授权码模式
授权服务器
源码参考 spring-security-oauth2 -> oauth2-authorization-code -> oauth2-authorization-code-qq
- 依赖 ^6
1 | <dependency> |
- 认证服务器配置(其他和基本类似)
1 |
|
- 获取资源数据
1 | // ### 1 |
客户端
源码参考 spring-security-oauth2 -> oauth2-authorization-code -> oauth2-authorization-code-aiqiyi(授权服务器为oauth2-authorization-code-qq)
- 获取token
1 |
|
访问资源流程
- 访问授权服务器:
http://localhost:8080/oauth/authorize?client_id=aiqiyi&response_type=code&redirect_uri=http://localhost:9090/jump
- 浏览器跳转到授权服务器登录页面:
http://localhost:8080/login
- 认证通过,获取到授权码,认证服务器重定向到:
http://localhost:9090/jump?code=TLFxg1
(进入客户端后台服务) - ajax访问客户端服务:
http://localhost:9090/get_info?code=TLFxg1
- 客户端服务请求认证服务器获取token:
http://localhost:8080/oauth/token
- 获取token成功:{“access_token”:”3b017a2d-3e3d-4536-b978-d3d8e05f4b05”,”token_type”:”bearer”,”refresh_token”:”4593b664-9107-404f-8e77-2073515b42c9”,”expires_in”:43199,”scope”:”get_user_info get_fanslist”}
- 获取用户信息(资源数据)
- 客户端服务请求认证服务器获取token:
- 浏览器地址变为:
http://localhost:9090/jump?code=TLFxg1
- 携带 access_token 访问资源服务器:
http://localhost:8080/qq/info?access_token=3b017a2d-3e3d-4536-b978-d3d8e05f4b05
(根据token可获取到用户信息)
客户端自动配置
源码参考 spring-security-oauth2 -> oauth2-authorization-code -> oauth2-authorization-code-youku(授权服务器为oauth2-authorization-code-qq)
- 依赖
1 | <!-- oauth2 客户端登录需要 --> |
- 配置
1 | spring: |
- 常见错误
authorization_request_not_found
=> 测试时客户端和认证服务器不能都使用localhost(会导致SESSIONID对应的cookie被覆盖)。可以修改hosts文件,增加一个域名映射到127.0.0.1
单点登录
- 测试说明
- 源码参考 spring-security-oauth2 -> oauth2-authorization-code -> oauth2-authorization-code-sso(授权服务器为oauth2-authorization-code-qq)
- 启动本项目下的client1、client2、resource,并启动第三方认证服务oauth2-authorization-code-qq
- 在hosts下增加
127.0.0.1 aezocn.local
(client1)、127.0.0.1 smalle.local
(client2) 的映射 - 访问
http://aezocn.local:8081/client1
- USER账号密码 123456/admin 进行登录
- 访问
http://smalle.local:8082/client2
会发现无需登录 - 同理,先登录client2,再访问client1也无需登录
- 授权服务器配置
1 | public void configure(ClientDetailsServiceConfigurer clients) throws Exception { |
- 依赖(以client1为例,登录client1后可直接访问client2的端点)
1 | <!-- oauth2 客户端登录需要 --> |
- 配置
1 | security: |
- java配置
1 | // ### 1.客户端配置 |
访问资源服务器
- 依赖同client1模式 ^3
- 配置
1 | security: |
- java
1 |
|
基于jwt
源码参考 spring-security-oauth2 -> oauth2-jwt
- 额外依赖
1 | <dependency> |
- java(以对称加密为例, 非对称加密直接看github源码)
1 | // ### web security(WebSecurityConfigurerAdapter) |
- 测试
1 | /* |
常见问题
- sso登录报错
Authentication Failed: Could not obtain access token
- 1.use server.context-path to move each App to different paths, note that you need to do this for both(两个应用使用不同的hostname)
- 2.set the server.session.cookie.name for one App to something different, e.g., APPSESSIONID(两个应用使用不同的SESSIONID名称)
.antMatchers("/api/write/**").access("hasRole('ROLE_USER') and #oauth2.hasScope('write')")
配置无效- HttpSecurity配置时路径顺序很重要,具体参考【授权码模式-sso单点登陆-资源服务器注释】
- 验证客户端时, scope会生效。password模式验证用户token时scope测试未生效
- password模式获取token,报错
TokenEndpoint : Handling error: NestedServletException, Handler dispatch failed; nested exception is java.lang.StackOverflowError
- 解决方法
AuthenticationManager authenticationManagerBean()
:https://github.com/spring-projects/spring-boot/issues/12395
- 解决方法
- 利用refresh_token无法刷新token
- 需要设置userDetailsService
debug日志
基于OAuth2,客户端携Token获取用户信息为例
1 | // ######### 1.正确访问日志(需开启debug日志配置. logging.level.org.springframework.security: DEBUG) |
源码解析
- 登录流程(请求权限验证相关拦截器处理过程)