一、前言
对于一个权限管理框架而言,无论是 Shiro 还是 Spring Security,最最核心的功能,无非就是两方面:
- 认证
- 授权
通俗点说,认证就是我们常说的登录,授权就是权限鉴别,看看请求是否具备相应的权限
Spring Security 支持多种不同的认证方式,这些认证方式有的是 Spring Security 自己提供的认证功能,有的是第三方标准组织制订的,主要有如下一些:
一些比较常见的认证方式:
- HTTP BASIC authentication headers:基于IETF RFC 标准。
- HTTP Digest authentication headers:基于IETF RFC 标准。
- HTTP X.509 client certificate exchange:基于IETF RFC 标准。
LDAP:跨平台身份验证。
- Form-based authentication:基于表单的身份验证。
- Run-as authentication:用户用户临时以某一个身份登录。
- OpenID authentication:去中心化认证。
除了这些常见的认证方式之外,一些比较冷门的认证方式,Spring Security 也提供了支持。
- Jasig Central Authentication Service:单点登录。
- Automatic "remember-me" authentication:记住我登录(允许一些非敏感操作)。
- Anonymous authentication:匿名登录。
......
作为一个开放的平台,Spring Security 提供的认证机制不仅仅是上面这些。如果上面这些认证机制依然无法满足你的需求,我们也可以自己定制认证逻辑。当我们需要和一些“老破旧”的系统进行集成时,自定义认证逻辑就显得非常重要了。
二、基于内存的用户认证
security默认使用的方式就是内存级的数据源认证
1、配置文件方式
当我们项目引入Spring Security后,默认的用户定义在SecurityProperties里边,是一个静态内部类,如果要定义自己的用户名密码,必然是要去覆盖默认配置,在配置文件中配置
spring.security.user.name=admin spring.security.user.password=admin spring.security.user.roles=ADMIN
此时重启项目,就可以使用自己定义的用户名/密码登录了。
2、配置类中重写configure方式
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean PasswordEncoder passwordEncoder() { //暂时先不给密码进行加密,所以返回 NoOpPasswordEncoder 的实例 return NoOpPasswordEncoder.getInstance(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() //如果需要配置多个用户,用 and 相连 .withUser("123") .password("123").roles("admin"); } }
3、配置类中重写userDetailsService方式
通过重写 WebSecurityConfigurerAdapter 中的 userDetailsService 方法来提供一个 UserDetailService 实例进而配置多个用户:
@Configuration public class SecurityConfig1 extends WebSecurityConfigurerAdapter { @Bean PasswordEncoder passwordEncoder(){ return NoOpPasswordEncoder.getInstance(); } @Override @Bean public UserDetailsService userDetailsServiceBean() throws Exception { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("admin").password("admin").roles("admin").build()); manager.createUser(User.withUsername("123").password("123").roles("user").build()); return manager; } }
4、自定义实现类方式
重写UserDetailsService接口中的loadUserByUsername方法,并在配置类里申明
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.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; /** * 自定义认证服务实现security中的方法 */ @Service public class MyUserDetailsServiceImpl implements UserDetailsService { //实现security接口类userdetails唯一方法 @Override public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { //这个设置一个角色 List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role"); return new User("123",getPass(),authorities); } //对配置的用户密码123进行加密 public String getPass(){ return new BCryptPasswordEncoder().encode("123"); } }
配置类里配置自定义的类
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import javax.annotation.Resource; @Configuration public class SecurityConfig1 extends WebSecurityConfigurerAdapter { //注入自己的实现类 @Resource private MyUserDetailsServiceImpl userDetailsService; @Bean PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } }
总结:以上四种基于内存的方式认证推荐第二种
三、基于数据库数据源
1、基于默认的数据库模型认证
UserDetailsService 都有哪些实现类
可以看到,在几个能直接使用的实现类中,除了 InMemoryUserDetailsManager 之外,还有一个 JdbcUserDetailsManager,使用 JdbcUserDetailsManager 可以让我们通过 JDBC 的方式将数据库和 Spring Security 连接起来。
JdbcUserDetailsManager 自己提供了一个数据库模型,这个数据库模型保存在如下位置:
org/springframework/security/core/userdetails/jdbc/users.ddl
这里面sql语句如下
create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null); create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username)); create unique index ix_auth_username on authorities (username,authority);
可以看到,脚本中有一种数据类型 varchar_ignorecase,这个其实是针对 HSQLDB 数据库创建的,而我们使用的 MySQL 并不支持这种数据类型,所以这里需要大家手动调整一下数据类型,将 varchar_ignorecase 改为 varchar 即可。
修改完成后,创建数据库,执行完成后的脚本。
执行完 SQL 脚本后,我们可以看到一共创建了两张表:users 和 authorities。
- users 表中保存用户的基本信息,包括用户名、用户密码以及账户是否可用。
- authorities 中保存了用户的角色。
- authorities 和 users 通过 username 关联起来。
配置完成后,接下来,我们将 InMemoryUserDetailsManager 提供的用户数据用 JdbcUserDetailsManager 代替掉,如下:
@Autowired DataSource dataSource; @Override @Bean protected UserDetailsService userDetailsService() { JdbcUserDetailsManager manager = new JdbcUserDetailsManager(); manager.setDataSource(dataSource); if (!manager.userExists("admin")) { manager.createUser(User.withUsername("admin").password("123").roles("admin").build()); } if (!manager.userExists("123")) { manager.createUser(User.withUsername("123").password("123").roles("user").build()); } return manager; }
- 首先构建一个 JdbcUserDetailsManager 实例。
- 给 JdbcUserDetailsManager 实例添加一个 DataSource 对象。
- 调用 userExists 方法判断用户是否存在,如果不存在,就创建一个新的用户出来(因为每次项目启动时这段代码都会执行,所以加一个判断,避免重复创建用户)。
- 用户的创建方法和我们之前 InMemoryUserDetailsManager 中的创建方法基本一致
因为要连接数据库,所以还需导入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
配置文件配置
spring.datasource.username=root spring.datasource.password=root spring.datasource.url=jdbc:mysql:///security?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
2、自定义数据库认证
Spring Security适应系统,而非让系统适应Spring Security,是Spring Security框架开发者和使用者的共识,上面使用了 InMemoryUserDetailsManager 和 JdbcUserDetailsManager 两个UserDetailsService 实现类。生效方式也很简单,只需加入 Spring 的 IoC 容器,就会被 Spring Security自动发现并使用。自定义数据库结构实际上也仅需实现一个自定义的UserDetailsService。
UserDetailsService仅定义了一个loadUserByUsername方法,用于获取一个UserDetails对象。UserDetails对象包含了一系列在验证时会用到的信息,包括用户名、密码、权限以及其他信息
这里结合mybatis认证,需要导入的依赖
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.0.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
自定义类
@Service public class MyUserDetailsServiceImpl implements UserDetailsService { @Resource private UserDao userDao; //实现security接口类userdetails唯一方法 @Override public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { SysUser user = userDao.getUserByUserName(userName); //这里查出结果如:userName = "admin" passWord="$2a$10$FsNmqBMoxqRQAzcjvF8YD.Sqh3SaSkO40FfuC.VraGuKTcTeC3wDm";密码是经过BCryptPasswordEncoder加密的 if(user==null){ throw new UsernameNotFoundException("用户不存在"); } //这个设置一个角色 List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role"); return new User(user.getUserName(),user.getPassWord(),authorities); } }
配置类
@Configuration public class SecurityConfig1 extends WebSecurityConfigurerAdapter { //注入自己的实现类 @Resource private MyUserDetailsServiceImpl userDetailsService; @Bean PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } }
四、配置方式认证
上面我们知道了自定义获取认证数据源几种方式,下面演示通过配置类的方式认证
前后端不分离配置
@Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .mvcMatchers("/user/login").permitAll() .anyRequest().authenticated() .and().formLogin() //=================登录界面配置================= .loginPage("/user/toLoginPage")//自定义访问登录页面地址 .loginProcessingUrl("/doLogin")//自定义登录请求的url .usernameParameter("name")//自定义用户名、密码变量名 .passwordParameter("pass") //=================认证配置================= .successForwardUrl("/index")//认证成功始终跳转的路径.defaultSuccessUrl("/hello") redirect 重定向跳转 但是优先跳转到请求的路径 ,可以传入第二个参数true总是重定向到hello .failureForwardUrl("/user/toLoginPage");//认证失败 跳转 错误信息在request中,.failureUrl("/login.html")认证失败 redirect跳转 错误信息在session中 } }
前后端分离配置
@Configuration public class MySecurityConfig1 extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .mvcMatchers("/user/login").permitAll() .anyRequest().authenticated() .and().formLogin() //=================登录认证================= .loginProcessingUrl("/doLogin") .usernameParameter("name") .passwordParameter("pass") .successHandler(new MyAuthenticationSuccessHandler())//登录成功 自定义逻辑处理 .failureHandler(new MFailureHandler());//登录失败 自定义逻辑处理 } }
定义登录成功的逻辑处理器
/** * 定义登录成功的逻辑处理器 */ public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { Map<String,Object> map = new HashMap<>(); map.put("code","200"); map.put("msg","success"); //认证信息 map.put("authentication",authentication); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSON.toJSONString(map)); } }
自定登录失败处理器
/** * 自定义认证失败处理器 */ public class MFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { Map<String,Object> map = new HashMap<>(); map.put("code","500"); map.put("msg","success"); //认证信息 map.put("exception",exception); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSON.toJSONString(map)); } }
五、自定义认证过滤器
Spring Security 登录请求是一个 POST 请求,但是数据传输格式是 key/value 的形式。整个项目里就只有这一个 POST 请求是这样,其他 POST 请求都是 JSON 格式的数据,如果也将登录请求写成json格式呢?
用户登录的用户名/密码是在 UsernamePasswordAuthenticationFilter
类中处理的,具体的处理代码如下
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String username = obtainUsername(request); String password = obtainPassword(request); //省略 } protected String obtainPassword(HttpServletRequest request) { return request.getParameter(passwordParameter); } protected String obtainUsername(HttpServletRequest request) { return request.getParameter(usernameParameter); }
从这段代码中,我们就可以看出来为什么 Spring Security 默认是通过 key/value 的形式来传递登录参数,因为它处理的方式就是 request.getParameter。
所以我们要定义成 JSON 的,思路很简单,就是自定义来定义一个过滤器代替 UsernamePasswordAuthenticationFilter
,然后在获取参数的时候,换一种方式就行了。
自定义一个过滤器
publicclass LoginFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { //首先登录请求肯定是 POST,如果不是 POST ,直接抛出异常,后面的也不处理了 if (!request.getMethod().equals("POST")) { thrownew AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } //因为要在这里处理验证码,所以第二步从 session 中把已经下发过的验证码的值拿出来 String verify_code = (String) request.getSession().getAttribute("verify_code"); //接下来通过 contentType 来判断当前请求是否通过 JSON 来传递参数,如果是通过 JSON 传递参数,则按照 JSON 的方式解析,如果不是,则调用 super.attemptAuthentication 方法,进入父类的处理逻辑中,也就是说,我们自定义的这个类,既支持 JSON 形式传递参数,也支持 key/value 形式传递参数 if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)) { Map<String, String> loginData = new HashMap<>(); try { //如果是 JSON 形式的数据,我们就通过读取 request 中的 I/O 流,将 JSON 映射到一个 Map 上 loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class); } catch (IOException e) { }finally { String code = loginData.get("code"); //从 Map 中取出 code,先去判断验证码是否正确,如果验证码有错,则直接抛出异常。这里省略验证码的判断逻辑 checkCode(response, code, verify_code); } String username = loginData.get(getUsernameParameter()); String password = loginData.get(getPasswordParameter()); if (username == null) { username = ""; } if (password == null) { password = ""; } username = username.trim(); //接下来从 Map 中取出 username 和 password,构造 UsernamePasswordAuthenticationToken 对象并作校验 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); setDetails(request, authRequest); returnthis.getAuthenticationManager().authenticate(authRequest); } else { checkCode(response, request.getParameter("code"), verify_code); returnsuper.attemptAuthentication(request, response); } } public void checkCode(HttpServletResponse resp, String code, String verify_code) { if (code == null || verify_code == null || "".equals(code) || !verify_code.toLowerCase().equals(code.toLowerCase())) { //验证码不正确 thrownew AuthenticationServiceException("验证码不正确"); } } }
过滤器定义完成后,接下来用我们自定义的过滤器代替默认的 UsernamePasswordAuthenticationFilter
,首先我们需要提供一个 LoginFilter 的实例
@Bean LoginFilter loginFilter() throws Exception { LoginFilter loginFilter = new LoginFilter(); loginFilter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); Hr hr = (Hr) authentication.getPrincipal(); hr.setPassword(null); RespBean ok = RespBean.ok("登录成功!", hr); String s = new ObjectMapper().writeValueAsString(ok); out.write(s); out.flush(); out.close(); } }); loginFilter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); RespBean respBean = RespBean.error(exception.getMessage()); if (exception instanceof LockedException) { respBean.setMsg("账户被锁定,请联系管理员!"); } elseif (exception instanceof CredentialsExpiredException) { respBean.setMsg("密码过期,请联系管理员!"); } elseif (exception instanceof AccountExpiredException) { respBean.setMsg("账户过期,请联系管理员!"); } elseif (exception instanceof DisabledException) { respBean.setMsg("账户被禁用,请联系管理员!"); } elseif (exception instanceof BadCredentialsException) { respBean.setMsg("用户名或者密码输入错误,请重新输入!"); } out.write(new ObjectMapper().writeValueAsString(respBean)); out.flush(); out.close(); } }); loginFilter.setAuthenticationManager(authenticationManagerBean()); loginFilter.setFilterProcessesUrl("/doLogin"); return loginFilter; }
当我们代替了 UsernamePasswordAuthenticationFilter
之后,原本在 SecurityConfig#configure 方法中关于 form 表单的配置就会失效,那些失效的属性,都可以在配置 LoginFilter 实例的时候配置。
另外记得配置一个 AuthenticationManager,根据 WebSecurityConfigurerAdapter 中提供的配置即可。
FilterProcessUrl 则可以根据实际情况配置,如果不配置,默认的就是 /login
。
最后,我们用自定义的 LoginFilter 实例代替 UsernamePasswordAuthenticationFilter
,如下
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() ... //省略 http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class); }