快速入门 简单案例 引入maven依赖:
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency >
测试:
访问任何一个现成的接口,例如( http://localhost:8080/hello )这时会自动跳转到一个默认登陆页面
默认用户名是user,密码会输出在控制台。
输入用户名user(默认值)和密码后,会再次跳回到hello的输入页面。
在浏览器输入 http://localhost:8080/logout 退出登录。
自定义登录页 将现成的登录页面拷贝到项目中的 resource 目录下的 static 目录
创建配置类SecurityConfig,配置登录页 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Configuration public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login.html" ) .loginProcessingUrl("/login" ) .permitAll() .and() .authorizeRequests() .antMatchers("/css/**" ,"/images/**" ).permitAll() .anyRequest().authenticated() .and() .csrf().disable(); return http.build(); } }
再次运行项目,我们就会看到自己编写的登录页面 获取当前用户 修改 HelloController 的hello方法
1 2 3 4 5 6 7 8 9 10 11 12 @RequestMapping("/hello") public String hello () { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String userName = authentication.getName(); Collection<GrantedAuthority> authorities = (Collection<GrantedAuthority>) authentication.getAuthorities(); UserAuth userAuth = (UserAuth) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return "hello " +userName; }
运行后效果如下:
SpringSecurity基本原理 Spring-Security其内部基础的处理方式就是通过过滤器来实现的,来我们看下刚才的例子用到的一些过滤器,如图所示:
这几个过滤器都是干啥的呢?
UsernamePasswordAuthenticationFilter :负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。ExceptionTranslationFilter :处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException。FilterSecurityInterceptor :负责权限校验的过滤器。更多的过滤器及其功能
认证 基于内存模型实现认证 修改配置类 SecurityConfig,添加两个bean的配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Bean public UserDetailsService users () { UserDetails user = User.builder() .username("user" ) .password("$2a$10$PxbYDOXTGAxhjZKgv6By4uG/1hVUJbIJUuVm1S.RTAGIn1C72h6jO" ) .roles("USER" ) .build(); UserDetails admin = User.builder() .username("admin" ) .password("$2a$10$908P2PWTjrTDL1RRNK61KuANJvGr6PBEa9hJJ572r0pTsQsKUfCIO" ) .roles("USER" , "ADMIN" ) .build(); return new InMemoryUserDetailsManager (user, admin); }
Spring Security 提供了一个 UserDetails 的实现类 User ,用于用户信息的实例表示。另外,User 提供 Builder 模式的对象构建方式。
再次测试,输入用户名 user ,密码 123456 BCrypt密码加密 明文密码肯定不安全,所以我们需要实现一个更安全的加密方式BCrypt。
BCrypt就是一款加密工具,可以比较方便地实现数据的加密工作。也可以简单理解为它内部自己实现了随机加盐处理。例如,使用MD5加密,每次加密后的密文其实都是一样的,这样就方便了MD5通过大数据的方式进行破解。 BCrypt生成的密文长度是60,而MD5的长度是32。
我们现在随便找个类,写个main方法测试一下
1 2 3 4 public static void main (String[] args) { String password = BCrypt.hashpw("000000" , BCrypt.gensalt()); System.out.println(password); }
输出结果如下:
1 $2a$10 $Tkv2PEx2RAx0dWqnpajlG.gJSdypYGCUS1feqStdrXTLhD2iXw8E.
BCrypt提供了一个方法,用于验证密码是否正确:
1 boolean checkpw = BCrypt.checkpw("000000" , "$2a$10$Tkv2PEx2RAx0dWqnpajlG.gJSdypYGCUS1feqStdrXTLhD2iXw8E." );
基于MySQL实现认证 在Spring Security框架中提供了一个UserDetailsService 接口,它的主要作用是提供用户详细信息。具体来说,当用户尝试进行身份验证时,UserDetailsService 会被调用,以获取与用户相关的详细信息。这些详细信息包括用户的用户名、密码、角色等
我们可以简单改造之前的代码,来快速熟悉一下UserDetailsService
执行流程如下:
新创建一个UserDetailsServiceImpl,让它实现UserDetailsService ,代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Component public class UserDetailsServiceImpl implements UserDetailsService { @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { if (username.equals("user" )){ UserDetails user = User.builder() .username("user" ) .password("$2a$10$PxbYDOXTGAxhjZKgv6By4uG/1hVUJbIJUuVm1S.RTAGIn1C72h6jO" ) .roles("USER" ) .build(); } if (username.equals("admin" )){ UserDetails admin = User.builder() .username("admin" ) .password("$2a$10$908P2PWTjrTDL1RRNK61KuANJvGr6PBEa9hJJ572r0pTsQsKUfCIO" ) .roles("USER" , "ADMIN" ) .build(); } return null ; } }
当前对象需要让spring容器管理,所以在类上添加注解@Component 注意一下loadUserByUsername方法的返回值,叫做UserDetails,这也是框架给提供了保存用户的类,并且也是一个接口,如果我们有自定义的用户信息存储(直接把用户角色存储到数据库,别名等),可以实现这个接口,我们后边会详细讲解 既然以上能使用这个类来查询用户信息,那么我们之前在SecurityConfig中定义的用户信息,可以注释掉了,如下:
我们可以重启项目,然后进行测试,发现跟之前没什么区别,一样是实现了安全校验
当然我们最终不能把用户静态的定义在代码中的,我们需要到数据库去查询用户,我们可以直接使用我们项目中的用户表,实现的步骤如下:
导入相关依赖(数据库、mybaitsplus、lombok等) 添加配置:连接数据库、mybatisplus配置等(application.yml) 生成实体类和mapper 改造UserDetailsServiceImpl(用户从数据库中获取) 引入依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <dependency > <groupId > com.alibaba</groupId > <artifactId > druid-spring-boot-starter</artifactId > <version > 1.2.1</version > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > <version > 3.5.3.1</version > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <version > 8.0.19</version > </dependency >
添加相关配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 spring: application: name: spring-security-demo datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.200.146:3306/security_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: heima123 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: ink.lusy.project.entity global-config: db-config: id-type: auto configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true cache-enabled: false
实体类 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 @Data @TableName(value = "sys_user", autoResultMap = true) public class User { @TableId(type = IdType.AUTO) private Long id; private String username; private String password; private String userType; private String avatar; private String nickName; private String email; private String realName; private String mobile; private String sex; @TableField(typeHandler = JacksonTypeHandler.class) private List<String> roles; }
对应的 SQL 建表语句如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID' , username VARCHAR (30 ) NOT NULL COMMENT '用户名' , password VARCHAR (100 ) NOT NULL COMMENT '密码' , user_type VARCHAR (2 ) DEFAULT '00' COMMENT '用户类型(0:系统用户,1:客户)' , avatar VARCHAR (255 ) DEFAULT '' COMMENT '头像地址' , nick_name VARCHAR (30 ) COMMENT '用户昵称' , email VARCHAR (50 ) UNIQUE COMMENT '邮箱' , real_name VARCHAR (30 ) UNIQUE COMMENT '真实姓名' , mobile VARCHAR (11 ) UNIQUE COMMENT '手机号码' , sex CHAR (1 ) DEFAULT '2' COMMENT '性别(0:男 1:女 2:未知)' , roles JSON COMMENT '用户角色列表' ) COMMENT = '用户表' ;
改造 UserDetailsServiceImpl 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 @Component @RequiredArgsConstructor public class UserDetailServiceImpl implements UserDetailsService { private final IUserService userService; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { if (username == null ) { throw new UsernameNotFoundException ("用户名不能为空" ); } User user = userService.lambdaQuery() .eq(User::getUsername, username) .one(); if (ObjectUtils.isEmpty(user)) { throw new RuntimeException ("用户不存在或已被禁用" ); } List<String> roles = user.getRoles(); List<GrantedAuthority> list = new ArrayList <>(); if (CollectionUtils.isNotEmpty(roles)){ for (String role : roles) { SimpleGrantedAuthority roleName = new SimpleGrantedAuthority (role); list.add(roleName); } } return new org .springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), list); } }
上述代码中,返回的UserDetails或者是User都是框架提供的类,我们在项目开发的过程中,很多需求都是我们自定义的属性(新增一个别名属性),我们需要扩展该怎么办?
其实,我们可以自定义一个类,来实现UserDetails,在自己定义的类中,就可以扩展自己想要的内容,如下代码:
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 @Data @Builder public class UserAuth implements UserDetails { private String username; private String password; private String nickName; private List<String> roles; @Override public Collection<? extends GrantedAuthority > getAuthorities() { if (roles==null ) return null ; return roles.stream() .map(role -> new SimpleGrantedAuthority ("ROLE_" +role)) .collect(Collectors.toList()); } @Override public boolean isAccountNonExpired () { return true ; } @Override public boolean isAccountNonLocked () { return true ; } @Override public boolean isCredentialsNonExpired () { return true ; } @Override public boolean isEnabled () { return true ; } }
然后,我们可以继续改造UserDetailsServiceImpl中检验用户的逻辑,代码如下:
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 @Component @RequiredArgsConstructor public class UserDetailServiceImpl implements UserDetailsService { private final IUserService userService; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { if (username == null ) { throw new UsernameNotFoundException ("用户名不能为空" ); } User user = userService.lambdaQuery() .eq(User::getUsername, username) .one(); if (ObjectUtils.isEmpty(user)) { throw new RuntimeException ("用户不存在或已被禁用" ); } return UserAuth.builder() .username(user.getUsername()) .password(user.getPassword()) .roles(user.getRoles()) .nickName(user.getNickName()) .build(); } }
修改HelloController,使用getPrincipal()方法读取认证主体对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @RestController public class HelloController { @RequestMapping("/hello") public String hello () { String name = SecurityContextHolder.getContext().getAuthentication().getName(); UserAuth userAuth = (UserAuth)SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return "hello :" +name+" 昵称:" +userAuth.getNickName(); } }
授权 授权的方式包括 web授权和方法授权,web授权是通过 url 拦截进行授权,方法授权是通过方法拦截进行授权。如果同时使用 web 授权和方法授权,则先执行web授权,再执行方法授权,最后决策都通过,则允许访问资源,否则将禁止访问。接下来,我们就主要学习web授权,方法授权是通过注解进行授权的,粒度较小,耦合度太高
WEB授权-简单例子 修改HelloController,增加两个方法 (根据hello方法复制后修改即可),主要是为了方便后边进行测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 @RequestMapping("/hello/user") public String helloUser () { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String name = authentication.getName(); return "hello-user " +name; } @RequestMapping("/hello/admin") public String helloAdmin () { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String name = authentication.getName(); return "hello-admin " +name; }
修改 SecurityConfig 的securityFilterChain方法 ,添加对以上两个地址的角色控制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Bean public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login.html" ) .loginProcessingUrl("/login" ) .permitAll() .and() .authorizeRequests() .antMatchers("/css/**" ,"/images/**" ).permitAll() .antMatchers("/hello/user" ).hasRole("USER" ) .antMatchers("/hello/admin" ).hasAnyRole("ADMIN" ) .anyRequest().authenticated() .and() .csrf().disable(); return http.build(); }
分别以user 和admin用户登录,进行测试 如果需要判断的 URI 数量较多,可以使用配置文件或者和对应用户一起存储在数据库的方法,在启动是加载配置来解决,这里就不展开细说了。
控制操作方法 上文只是将请求接口路径与配置的规则进行匹配,那匹配成功之后应该进行什么操作呢?Spring Security 内置了一些控制操作。
permitAll() 方法,所有用户可访问。 denyAll() 方法,所有用户不可访问。 authenticated() 方法,登录用户可访问。 anonymous() 方法,匿名用户可访问。 rememberMe() 方法,通过 remember me 登录的用户可访问。 fullyAuthenticated() 方法,非 remember me 登录的用户可访问。 hasIpAddress(String ipaddressExpression) 方法,来自指定 IP 表达式的用户可访问。 hasRole(String role) 方法, 拥有指定角色的用户可访问,传入的角色将被自动增加 “ROLE_” 前缀。 hasAnyRole(String… roles) 方法,拥有指定任意角色的用户可访问。传入的角色将被自动增加 “ROLE_” 前缀。 hasAuthority(String authority) 方法,拥有指定权限( authority )的用户可访问。ROLE_
前缀不会自动添加 hasAnyAuthority(String… authorities) 方法,拥有指定任意权限( authority )的用户可访问。ROLE_
前缀不会自动添加 1 2 3 4 5 6 7 8 .antMatchers("/hello/admin" ).hasAuthority("ROLE_admin" ) .antMatchers("/admin/user" ).hasAnyAuthority("ROLE_admin" ,"ROLE_user" ) .antMatchers("/security/**" ).hasRole("user" ) .antMatchers("/admin/demo" ).hasIpAddress("192.168.200.129" )
SpringSecurity整合JWT 前后端分离的权限方案
我们前几个小节,实现的是非前后端分离情况下的认证与授权的处理,目前大部分项目,都是使用前后端分离的模式。那么前后端分离的情况下,我们如何使用SpringSecurity来解决权限问题呢?最常见的方案就是SpringSecurity+JWT
整体实现思路:
实现登录 引入依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <dependency > <groupId > com.auth0</groupId > <artifactId > java-jwt</artifactId > <version > 3.8.1</version > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt</artifactId > <version > 0.9.1</version > </dependency > <dependency > <groupId > cn.hutool</groupId > <artifactId > hutool-all</artifactId > <version > 5.8.0.M3</version > </dependency > <dependency > <groupId > javax.xml.bind</groupId > <artifactId > jaxb-api</artifactId > </dependency >
创建 JwtUtil 工具类 ,用于生成和验证 JWT令牌 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 public class JwtUtil { public static String createJWT (String secretKey , int dateOffset, Map<String, Object> claims) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; JwtBuilder builder = Jwts.builder() .setClaims(claims) .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) .setExpiration(DateUtil.offset(new Date (), DateField.HOUR_OF_DAY, dateOffset)); return builder.compact(); } public static Claims parseJWT (String secretKey, String token) { try { Claims claims = Jwts.parser() .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token).getBody(); return claims; } catch (Exception e) { throw new RuntimeException ("没有权限,请登录" ); } } }
创建LoginController 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 @RequestMapping("/security") @RestController @RequiredArgsConstructor public class LoginController { private final AuthenticationManager authenticationManager; @PostMapping("/login") public String login (@RequestBody LoginDto loginDto) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (loginDto.getUsername(), loginDto.getPassword()); Authentication authenticate = authenticationManager.authenticate(authenticationToken); if (authenticate.isAuthenticated()) { Object principal = authenticate.getPrincipal(); Map<String, Object> map = new HashMap <>(); map.put("user" , principal); String token = JwtUtil.createJWT("lusy" , 360000 , map); return token; }else { return "" ; } } }
修改SecurityConfig 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 @Configuration public class SecurityConfig { @Autowired private TokenAuthorizationManager tokenAuthorizationManager; @Bean public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { http .authorizeHttpRequests().antMatchers("/security/login" ).permitAll() .and() .csrf().disable(); return http.build(); } @Bean public AuthenticationManager authenticationManager (AuthenticationConfiguration auth) throws Exception { return auth.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } }
使用ApiFox测试
自定义授权管理器 执行流程 当用户登录以后,携带了token访问后端,那么此时Spring Security框架就要对当前请求进行验证,验证包含了两部分,第一验证携带的token是否合法,第二验证当前用户是否拥有当前访问资源的权限
自定义授权管理器TokenAuthorizationManager 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 @Component public class TokenAuthorizationManager implements AuthorizationManager <RequestAuthorizationContext> { @Override public AuthorizationDecision check (Supplier<Authentication> authentication, RequestAuthorizationContext requestAuthorizationContext) { HttpServletRequest request = requestAuthorizationContext.getRequest(); String token = request.getHeader("token" ); if (StrUtil.isBlank(token)) { return new AuthorizationDecision (false ); } Claims claims = JwtUtil.parseJWT("lusy" , token); if (CollUtil.isEmpty(claims)) { return new AuthorizationDecision (false ); } UserAuth userAuth = MapUtil.get(claims, "user" , UserAuth.class); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken (userAuth, userAuth.getPassword(), userAuth.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); if (userAuth.getRoles().contains("ADMIN" )) { if ("/hello/admin" .equals(request.getRequestURI())){ return new AuthorizationDecision (true ); } } if (userAuth.getRoles().contains("USER" )){ if ("/hello/user" .equals(request.getRequestURI())){ return new AuthorizationDecision (true ); } } return new AuthorizationDecision (false ); } }
这里的 URI 目前是写死的,如果需要匹配多个可以使用配置文件或和用户一起写入数据库,验证时使用 List<String>
的 contains(Object o)
方法就可以轻松实现
修改SecurityConfig,注册授权管理器
并同时关闭session和缓存,前后端分离项目不需要使用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 @Configuration public class SecurityConfig { @Autowired private TokenAuthorizationManager tokenAuthorizationManager; @Bean public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { http .authorizeHttpRequests().antMatchers("/security/login" ).permitAll() .anyRequest().access(tokenAuthorizationManager) .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .headers() .cacheControl().disable() .and() .csrf().disable(); return http.build(); } @Bean public AuthenticationManager authenticationManager (AuthenticationConfiguration auth) throws Exception { return auth.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } }
测试一:
登录账号:user@qq.com 拥有角色:USER
可以访问:/hello/user
其他请求返回403
测试二:
登录账号:admin@qq.com 拥有角色:USER、ADMIN
可以访问:/hello/user、/hello/user
附单词表:
单词 音标 解释 Security səˈkjʊrəti
安全 Authentication ɔːˌθentɪˈkeɪʃən
身份认证,衍生词:Authenticated(被认证过的) Authorization ˌɔːθəraɪˈzeɪʃən
访问授权,衍生词:Authorize、Authority Permit ˈpɜːmɪt
许可证 Matchers ˈmætʃərz
匹配器 Granted ɡræntɪd
授予特定的权限 Principal ˈprɪnsəpl
被认证和授权访问资源或系统的实体或用户