SpringSecurity总结
图片如果太小可以右键在新标签打开或者按住 ctrl+鼠标滑轮调整页面尺寸调整。
基础
核心
认证与授权
与Shiro联系
SpringSecurity 在 SpringBoot 出现前因为配置复杂使用较少,但是在SpringBoot 出现后搭配使用开发效率大大提高。是一款重量级框架。而 Shiro 是一款轻量级框架,配置简单一些,所以如果不使用 SpringBoot,那么一般搭配 Shiro,而使用SpringBoot 就搭配 SpringSecurity。
核心接口
UserDetailsService
定义了SpringSecurity 查询用户信息的接口方法,在SpringSecurity 认证时,并不是直接通过用户名密码去数据库比对,没有对应就返回,而是先通过 username 去数据库查到对应的用户信息,然后进行拼接成 SpringSecurity 内部维护的用户对象,然后由内部方法进行密码比对。而查询数据库返回用户对象的接口方法就是由 UserDetailsService 接口定义的。
UserDetails
上面说到数据库查询用户信息会返回一个SpringSecurity 内部维护的用户对象。这个用户抽象类就是 UserDetails,其内部结构如下
public interface UserDetails extends Serializable { // ~ Methods // ======================================================================================================== /** * Returns the authorities granted to the user. Cannot return <code>null</code>. * 授权列表 * @return the authorities, sorted by natural key (never <code>null</code>) */ Collection<? extends GrantedAuthority> getAuthorities(); /** * Returns the password used to authenticate the user. * * @return the password */ String getPassword(); /** * Returns the username used to authenticate the user. Cannot return <code>null</code>. * * @return the username (never <code>null</code>) */ String getUsername(); /** * Indicates whether the user's account has expired. An expired account cannot be * authenticated. * 是否过期 * @return <code>true</code> if the user's account is valid (ie non-expired), * <code>false</code> if no longer valid (ie expired) */ boolean isAccountNonExpired(); /** * Indicates whether the user is locked or unlocked. A locked user cannot be * authenticated. * 是否锁定,如果锁定就无法验证 * @return <code>true</code> if the user is not locked, <code>false</code> otherwise */ boolean isAccountNonLocked(); /** * Indicates whether the user's credentials (password) has expired. Expired * credentials prevent authentication. * 用户凭证是否过期,过期的凭证会阻止身份验证 * @return <code>true</code> if the user's credentials are valid (ie non-expired), * <code>false</code> if no longer valid (ie expired) */ boolean isCredentialsNonExpired(); /** * Indicates whether the user is enabled or disabled. A disabled user cannot be * authenticated. * 用户是启用还是禁用,无法对禁用的用户进行身份验证 * @return <code>true</code> if the user is enabled, <code>false</code> otherwise */ boolean isEnabled(); }
在使用时可以让自定义用户来实现这个接口。
PasswordEncoder
密码接口,一般使用 BCryptPasswordEncoder 来作为默认的密码转换器。SpringSecurity 在加密时引入盐,使得加密过程是不可逆的,而加密后的字符串包含盐信息,在比较方法中会对加密后的密码进行解析,解析出盐值,然后对输入密码进行加密,比较输入密码加密后的结果是否与原密码加密后的结果一致。使用 encode 方法进行加密, matches 方法进行密码比较。如果一致返回 true。
常用配置
用户名密码配置
方式一、配置文件
方式二、配置类
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Resource private PasswordEncoder passwordEncoder; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { String encode = passwordEncoder.encode("123"); auth.inMemoryAuthentication().withUser("lucy").password(encode).roles("admin"); // super.configure(auth); } @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }
方式三、自定义配置
因为一般项目用户名密码都是存在数据库的,所以这是最主流的。
1、配置UserDetails,返回用户信息
@Service("userDetailsService") public class MyUserDetailService implements UserDetailsService { @Resource private PasswordEncoder passwordEncoder; @Resource private UsersMapper usersMapper; @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { Users user = usersMapper.selectOne(new QueryWrapper<Users>().eq("username", s)); if(user == null){ throw new UsernameNotFoundException("用户名不存在!"); } // 权限列表,role List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("role"); return new User(s, passwordEncoder.encode(user.getPassword()),auths); } }
2、添加配置类,将userDetails注册进 SpringSecurity
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Resource private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
记住我
原理
在登陆后会向数据库的 persistent_logins 表中插入一条记录,表结构如下
series 是主键, 随后将 series 和 token 进行算法转换成字符串发给客户端,后面客户端会携带 Cookie ,当下次访问时后端会解析 Cookie ,解析成 series 和 token ,然后去表中匹配,验证token是否一致,以及 last_used + 存活时间是否到期,如果都满足就再以 name 走 UserDetailsService 的方法,返回用户信息。
配置
建表语句:
DROP TABLE IF EXISTS `persistent_logins`; CREATE TABLE `persistent_logins` ( `username` varchar(64) NOT NULL, `series` varchar(64) NOT NULL, `token` varchar(64) NOT NULL, `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`series`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
@Resource private DataSource datasource; /** * 注入记住我token表的数据源 * @return */ @Bean public PersistentTokenRepository persistentTokenRepository(){ JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(datasource); // jdbcTokenRepository.setCreateTableOnStartup(true); // 是否自动创建token数据表,如果是第一次可以勾选,后面表存在还开启就会报错 return jdbcTokenRepository; }
可以使用在配置方法中添加 ".rememberMeParameter("rem") " 配置记住我功能的name
注意:
1、这里的 last_used 是拒上次打开浏览器登陆开始计算的,也就是每次打开浏览器访问一次 last_used 都会刷新一次。而浏览器内部访问并不会刷新时间。
2、退出后(退出登陆状态)会清除数据库的token数据记录,再次访问需要重新登录
登陆成功处理器
步骤一:增加组件
方式一、继承实现类
@Slf4j @Component public class AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { User user = (User) authentication.getPrincipal(); // 获取Security 内部维护的user对象 System.out.println(user.getUsername()); // 用户名:aa System.out.println(user.getPassword()); // 密码,由于加密,得到的是null:null System.out.println(user.getAuthorities()); // 用户权限列表:[ROLE_AAA, ROLE_sale, admins, manager] response.sendRedirect("http://www.baidu.com"); } }
方式二、实现底层接口
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private String url; public MyAuthenticationSuccessHandler(String url) { this.url = url; } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.sendRedirect(url); } }
步骤二:将组件注册进成功处理器配置中
登陆失败处理器
步骤一:增加组件
方式一、实现接口
方式二、继承实现类
步骤二:将组件注册进失败处理器配置中
权限认证失败处理器
1、组件
2、配置
用户退出处理器
1、组件
2、配置
角色权限
访问一个需要权限或角色的页面需要先登陆,如果登陆后还是不能访问就会返回500.
角色、权限、用户关系
权限与角色是多对多,角色与用户也是多对多。权限指的是对某个表具体的增删改查权限,而角色是一系列权限的集合。比如管理员角色拥有对所有表增删改查的权限,普通用户角色只拥有对所有表查询的权限,而用户 admin 拥有管理员角色,用户 A 拥有普通用户的角色。
定义权限
在config里配置路径所需权限,在UserDetailsService里配置用户所拥有的权限。
1、hasAuthority 是与关系,如果在config里配置了多个权限,如”admin,manager”,那么在UserDetailsService也必须对用户配置两个角色权限才可以访问
2、hasAnyAuthority 是或关系,如果在 config里配置了多个权限,如”admin,manager”,那么在UserDetailsService只需要对用户配置一个权限就可以访问
定义角色
角色在 UserDetailsService 实现类中配置需要加 "ROLE_" 前缀
而hasRole 和hasAnyRole 对应权限里的hasAuthority 和hasAnyAuthority,是与和或的关系。
Access 来定义权限、角色
上面的hasRole、hasAuthority 底层都是使用 access 来实现的,所以我们还可以通过底层的access 方法来主直接定义权限、角色。
那么 config 里配置就是如下:
自定义 Access 校验规则
1、组件
2、配置
基于 IP 来限制
这样的话只能接收来自 127.0.0.1 的请求。
定义角色注解
@Secured单个””里不支持使用,隔开,也就是不支持与关系。如果要配置多个或关系,可以使用{}, 在UserDetailsService里只要配置一个就可以访问。
并且只支持定义角色,不支持定义权限,也就是Secured里必须是ROLE_开头
定义角色、权限注解
可以定义角色、也可以定义权限
如果用户拥有的角色是abc,那么在这里可以配置hasRole(‘abc’),也可以配置hasRole(‘ROLE_abc’),而使用config配置类配置则不可以,会报错。而大小写则和配置类一样会区分
先执行后校验注解
可以用于记录访问日志
对返回和传入数据过滤注解
CSRF
CSRF 是为了防止用户在开启记住登陆后,其他非法用户截取到登陆用户的 Cookie ,登陆其他用户进行非法操作。
默认是开启的,开启后用户登录时,系统发放一个CsrfToken值(key是 _csrf,value是token值),用户携带该CsrfToken值与用户名、密码等参数完成登录。系统记录该会话的 CsrfToken 值,之后在用户的任何请求中,都必须带上该CsrfToken值,并由系统进行校验。
配置:
相关依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!--对Thymeleaf添加Spring Security标签支持--> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity5</artifactId> </dependency>
开启 CSRF 时配置类不能配置 loginProcessingUrl 和 defaultSuccessUrl 。会影响登陆跳转逻辑。
其他配置
1、如果配置了登陆的URL(也就是loginProcessingUrl),那么自定义Controller里处理的登陆请求就会用不到,走的是SpringSecurity内部的验证方法。
2、anyRequest()必须配置在所有的antMatches后面,也就是笼统的权限配置必须放在其他权限的最后
3、and()是用于连接多个http配置。
4、在开发时需要添加@EnableWebSecurity注解,这个注解会自动配置安全认证策略和认证信息。
整合OAuth2
关于 OAuth2 与 JWT 可以移步 浅谈常见的认证机制 。
基础依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
基础配置
因为 OAuth2 涉及到资源服务器和授权服务器,所以除了配置 SpringSecurity ,还需要配置资源服务器和授权服务器。
1、SpringSecurity配置:
@EnableWebSecurity @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/oauth/**", "/login/**", "/logout/**").permitAll() .anyRequest().authenticated() .and() .formLogin().permitAll() .and() .csrf().disable(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
2、授权服务器配置:定义 app_id、app_secret,以及重定向地址,授权范围等
这里直接贴下包含下面整合 redis 存储、JWT、SSO总的配置,根据图片需要进行选择
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Resource private PasswordEncoder passwordEncoder; @Resource private AuthenticationManager authenticationManager; @Resource private UserService userDetailsService; @Resource private TokenStore jwtTokenStore; // 使用jwt存储(因为jwt是无状态的,所以并不会持久化) // @Resource // private TokenStore redisTokenStore; // 使用redis存储 @Resource private JwtAccessTokenConverter jwtAccessTokenConverter; @Resource private JwtTokenEnhancer jwtTokenEnhancer; /** * @param endpoints * @throws Exception */ @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { // 设置 JWT 增强内容 TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); List<TokenEnhancer> delegates = new ArrayList<>(); delegates.add(jwtTokenEnhancer); delegates.add(jwtAccessTokenConverter); tokenEnhancerChain.setTokenEnhancers(delegates); endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService) // 密码模式需要配置的 .tokenStore(jwtTokenStore) .tokenEnhancer(tokenEnhancerChain) // 增加额外数据 .accessTokenConverter(jwtAccessTokenConverter); // 使用jwt来代替默认的令牌 } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() // 放入内存 .withClient("client") // 客户端ID .secret(passwordEncoder.encode("112233")) // 密钥 // 重定向地址,这里整合SSO设置为客户端的login页面是因为SpringSecurity默认登陆页面的URL就是login,在客户端通过授权服务器通过后 // 携带令牌重定向到客户端8081的login页面,自动解析令牌完成登陆。 .redirectUris("http://www.baidu.com") .scopes("all") // 授权范围 .autoApprove(true) // 开启自动授权(不需要进入授权页面手动选择授权) .accessTokenValiditySeconds(60) // 过期时间,单位s .refreshTokenValiditySeconds(86400) // 刷新令牌过期时间 .authorizedGrantTypes("authorization_code","password","refresh_token"); //授权类型: // authorization_code:授权码模式 // password:密码模式 // refresh_token:支持刷新令牌 } /** * 配置单点登陆 * @param security * @throws Exception */ @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.tokenKeyAccess("isAuthenticated()"); } }
3、资源服务器配置:定义资源服务器资源权限角色配置。
4、其他:userDetailsService 配置
@Service("userDetailsService") public class UserService implements UserDetailsService { @Resource private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { // 权限列表,role List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admin1"); return new User(s, passwordEncoder.encode("123456"),auths); } }
自定义用户实体类 user ,权限属性全部设为 true。
public class User implements UserDetails { private String username; private String password; private List<GrantedAuthority> authorities; public User(String username, String password, List<GrantedAuthority> authorities) { this.username = username; this.password = password; this.authorities = authorities; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
资源服务器的资源Controller
同样贴上完整代码,根据图片需要选择
@RestController @RequestMapping("/user") public class UserController { @RequestMapping("/getCurrentUser") public Object getCurrentUser(HttpServletRequest request,Authentication authentication){ String authorization = request.getHeader("Authorization"); String token = authorization.substring(authorization.lastIndexOf("bearer") + 7); return Jwts.parser() .setSigningKey("test_key".getBytes(StandardCharsets.UTF_8)) // 密钥必须和加密所用的一致 .parseClaimsJws(token) .getBody(); // return authentication.getPrincipal(); } }
授权码模式
在上面的授权服务器配置中,已将授权类型设为 授权码模式,所以直接使用上面的配置。
验证
1、获取授权码
访问 http://localhost:8080/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all ,在登陆成功后(因为走的是上面 userDetailsService 的方法,所以用户名任意,密码123456就通过登陆),会重定向授权服务器配置中配置好的 http://www.baidu.com 。并且携带授权服务器返回的授权码 code。
2、获取授权令牌
接下来就可以再次访问 localhost:8080/oauth/token 携带授权码及其他数据来向授权服务器获取授权令牌。
3、通过令牌访问资源服务器的资源,访问资源服务器上资源的 URL,并携带授权令牌。
密码模式
密码模式因为是通过密码直接获取授权令牌,所以不需要先获取授权码,同时需要设置自定义的 userDetailsService 实现类,以及 authenticationManager 组件
1、ServurityConfig里增加配置:
2、授权服务器增加配置:
这样配置是同时支持授权码模式与密码模式
验证
通过密码获取授权令牌
访问资源服务器的资源则和授权码模式验证一样。
整合 redis 将令牌存入 redis
1、引入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>
2、注册 redis 的 tokenStore 组件进容器
@Configuration public class RedisConfig { @Resource private RedisConnectionFactory redisConnectionFactory; @Bean public TokenStore redisTokenStore(){ return new RedisTokenStore(redisConnectionFactory); } }
3、在授权服务器里注册 tokenStore
4、在配置文件里配置 redis 地址密码等。
使用 JWT 作为令牌
1、增加依赖
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency>
2、从容器中移除 redis 的 tokenStore 组件,同时想容器中加入 jwt 的 tokenStore 组件,并且配置 jwt 的转换器
完整代码,根据图片需要自取
@Configuration public class JwtTokenStoreConfig { @Resource private JwtAccessTokenConverter jwtAccessTokenConverter; // 保存 token的组件 @Bean public TokenStore jwtTokenStore(){ return new JwtTokenStore(jwtAccessTokenConverter); } // Jwt 转换器,用于将jwt转换成 OAuth2的令牌 @Bean public JwtAccessTokenConverter jwtAccessTokenConverter(){ JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter(); // 设置jwt密匙 jwtAccessTokenConverter.setSigningKey("test_key"); return jwtAccessTokenConverter; } // 配置Jwt的附加信息 @Bean public JwtTokenEnhancer jwtTokenEnhancer(){ return new JwtTokenEnhancer(); } }
3、注册进授权服务器
JWT 增加额外信息
1、增加 Jwt 附加信息组件并注册进容器
public class JwtTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) { HashMap<String, Object> map = new HashMap<>(); map.put("enhance", "enhancer info"); ((DefaultOAuth2AccessToken)oAuth2AccessToken).setAdditionalInformation(map); return oAuth2AccessToken; } }
2、在授权服务器里配置 jwt 附加信息组件
3、验证,修改资源服务器资源返回的信息
设置过期时间和刷新令牌
在授权服务器里增加配置:
在60s后token令牌(access_token)失效后,可以使用刷新令牌重新获取新的令牌,新的令牌过期时间也是60s。
因为密码模式不支持刷新令牌,所以通过授权码模式使用刷新令牌来获取新的令牌
通过刷新令牌获取令牌
整合SSO(单点登陆)
整合 SSO 后验证的原理就成了下面
各个服务模块都使用同一个授权服务器,也就是图中的认证中心,在第一次访问模块A时会去跳转到授权服务器进行验证,如果通过,那么就会返回给前端一个 token 令牌,以后在访问A或B时,都会携带这个令牌,而验证时都是通过同一个授权服务器验证,所以都会解析通过,进而访问对应的资源。
1、引入依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency>
2、新建一个模块,配置 SSO 访问授权服务器的地址
server.port=8081
#防止Cookie冲突,冲突会导致登陆验证不通过
server.servlet.session.cookie.name=OAUTH2-CLIENT-SESSIONID1
#授权服务器地址
oauth2-server-url: http://localhost:8080
#与授权服务器对应的配置
security.oauth2.client.client-id=client
security.oauth2.client.client-secret=112233
#获取授权码地址
security.oauth2.client.user-authorization-uri=${oauth2-server-url}/oauth/authorize
#获取令牌地址
security.oauth2.client.access-token-uri=${oauth2-server-url}/oauth/token
#获取jwt令牌地址
security.oauth2.resource.jwt.key-uri=${oauth2-server-url}/oauth/token_key
3、增加 SSO 模块的资源
@RestController @RequestMapping("/user") public class UserController { @RequestMapping("/getCurrentUser") public Object getCurrentUser(HttpServletRequest request,Authentication authentication){ return authentication; } }
4、主程序开启 OAuth2 自动配置
5、在授权服务器的配置增加配置
随后访问客户端资源 http://localhost:8081/user/getCurrentUser 就会先跳转到 http://localhost:8080/login ,也就是授权服务器进行授权验证,通过后经重定向回到 http://localhost:8081/login ,也就是客户端的登陆页面,并且携带授权服务器提供的jwt令牌,所以会自动解析通过验证,最后再访问客户端的资源