spring security簡介與使用


spring security

spring security使用目的:驗證,授權,攻擊防護。

原理:創建大量的filter和interceptor來進行請求的驗證和攔截,以此來達到安全的效果。

新建一個springboot項目

創建一個springboot項目,添加一個/helloController

@RestController
public class HelloController {

    @RequestMapping("/hello")
    public String hello(){
        return "hello";
    }
}

這樣,這個/hello是可以默認訪問,返回一個hello字符串。

添加spring security

向pom.xml中添加security依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

這樣在啟動的時候會在控制台顯示隨機生成的密碼。

console密碼顯示

這時訪問http://localhost:8080/hello會重定向到http://localhost:8080/login,這個頁面是spring默認的。

默認的登錄頁

登錄

使用默認用戶和隨機生成的密碼登錄

spring security 默認的用戶名是user,spring security啟動的時候會生成默認密碼(在啟動日志中可以看到)。

我們填入user 和 上圖顯示的fa028beb-31f0-4ccd-be91-31ba4a0cdb8d,那么就會正常的訪問/hello

image-20201130141751390

使用yaml文件定義的用戶名、密碼登錄

在application.yaml中定義用戶名密碼:

spring:
  security:
    user:
      name: root
      password: root

使用root/root登錄,可以正常訪問/hello

使用代碼中指定的用戶名、密碼登錄

  • 使用configure(AuthenticationManagerBuilder) 添加認證。
  • 使用configure(httpSecurity) 添加權限
@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("admin") // 添加用戶admin
                .password("{noop}admin")  // 不設置密碼加密
                .roles("ADMIN", "USER")// 添加角色為admin,user
                .and()
                .withUser("user") // 添加用戶user
                .password("{noop}user") 
                .roles("USER")
            	.and()
            	.withUser("tmp") // 添加用戶tmp
                .password("{noop}tmp")
            	.roles(); // 沒有角色
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/product/**").hasRole("USER") //添加/product/** 下的所有請求只能由user角色才能訪問
                .antMatchers("/admin/**").hasRole("ADMIN") //添加/admin/** 下的所有請求只能由admin角色才能訪問
                .anyRequest().authenticated() // 沒有定義的請求,所有的角色都可以訪問(tmp也可以)。
                .and()
                .formLogin().and()
                .httpBasic();
    }
}

添加AdminController、ProductController

@RestController
@RequestMapping("/admin")
public class AdminController {
    @RequestMapping("/hello")
    public String hello(){
        return "admin hello";
    }
}
@RestController
@RequestMapping("/product")
public class ProductController {
    @RequestMapping("/hello")
    public String hello(){
        return "product hello";
    }
}

通過上面的設置,訪問http://localhost:8080/admin/hello只能由admin訪問,http://localhost:8080/product/hello admin和user都可以訪問,http://localhost:8080/hello 所有用戶(包括tmp)都可以訪問。

使用數據庫的用戶名、密碼登錄

添加依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

添加數據庫配置

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

配置spring-security認證和授權

@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)// 設置自定義的userDetailsService
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/product/**").hasRole("USER")
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated() //
                .and()
                .formLogin()
                .and()
                .httpBasic()
                .and().logout().logoutUrl("/logout");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();// 使用不使用加密算法保持密碼
//        return new BCryptPasswordEncoder();
    }
}

如果需要使用BCryptPasswordEncoder,可以先在測試環境中加密后放到數據庫中:

@Test
void encode() {
    BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
    String password = bCryptPasswordEncoder.encode("user");
    String password2 = bCryptPasswordEncoder.encode("admin");
    System.out.println(password);
    System.out.println(password2);
}

配置自定義UserDetailsService來進行驗證

@Component("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {

   @Autowired
   UserRepository userRepository;

   @Override
   public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
         // 1. 查詢用戶
      User userFromDatabase = userRepository.findOneByLogin(login);
      if (userFromDatabase == null) {
         //log.warn("User: {} not found", login);
       throw new UsernameNotFoundException("User " + login + " was not found in db");
            //這里找不到必須拋異常
      }

       // 2. 設置角色
      Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
      GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(userFromDatabase.getRole());
      grantedAuthorities.add(grantedAuthority);

      return new org.springframework.security.core.userdetails.User(login,
            userFromDatabase.getPassword(), grantedAuthorities);
   }

}

配置JPA中的UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User findOneByLogin(String login);
}

添加數據庫數據

image-20201130200749622

CREATE TABLE `user` (
  `id` int(28) NOT NULL,
  `login` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `password` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `role` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO `demo`.`user`(`id`, `login`, `password`, `role`) VALUES (1, 'user', 'user', 'ROLE_USER');
INSERT INTO `demo`.`user`(`id`, `login`, `password`, `role`) VALUES (2, 'admin', 'admin', 'ROLE_ADMIN');

默認角色前綴必須是ROLE_,因為spring-security會在授權的時候自動使用match中的角色加上ROLE_后進行比較。

獲取登錄信息

@RequestMapping("/info")
public String info(){
    String userDetails = null;
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    if(principal instanceof UserDetails) {
        userDetails = ((UserDetails)principal).getUsername();
    }else {
        userDetails = principal.toString();
    }
    return userDetails;
}

使用SecurityContextHolder.getContext().getAuthentication().getPrincipal();獲取當前的登錄信息。

Spring Security 核心組件

SecurityContext

SecurityContext是安全的上下文,所有的數據都是保存到SecurityContext中。

可以通過SecurityContext獲取的對象有:

  • Authentication

SecurityContextHolder

SecurityContextHolder用來獲取SecurityContext中保存的數據的工具。通過使用靜態方法獲取SecurityContext的相對應的數據。

SecurityContext context = SecurityContextHolder.getContext();

Authentication

Authentication表示當前的認證情況,可以獲取的對象有:

UserDetails:獲取用戶信息,是否鎖定等額外信息。

Credentials:獲取密碼。

isAuthenticated:獲取是否已經認證過。

Principal:獲取用戶,如果沒有認證,那么就是用戶名,如果認證了,返回UserDetails。

UserDetails

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();
	String getPassword();
	String getUsername();
	boolean isAccountNonExpired();
	boolean isAccountNonLocked();
	boolean isCredentialsNonExpired();
	boolean isEnabled();
}

UserDetailsService

UserDetailsService可以通過loadUserByUsername獲取UserDetails對象。該接口供spring security進行用戶驗證。

通常使用自定義一個CustomUserDetailsService來實現UserDetailsService接口,通過自定義查詢UserDetails。

AuthenticationManager

AuthenticationManager用來進行驗證,如果驗證失敗會拋出相對應的異常。

PasswordEncoder

密碼加密器。通常是自定義指定。

BCryptPasswordEncoder:哈希算法加密

NoOpPasswordEncoder:不使用加密

spring security session 無狀態支持權限控制(前后分離)

spring security會在默認的情況下將認證信息放到HttpSession中。

但是對於我們的前后端分離的情況,如app,小程序,web前后分離等,httpSession就沒有用武之地了。這時我們可以通過configure(httpSecurity)設置spring security是否使用httpSession。

@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    // code...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
            	//設置無狀態,所有的值如下所示。
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // code...
    }
    // code...
}

共有四種值,其中默認的是ifRequired。

  • always – a session will always be created if one doesn’t already exist,沒有session就創建。
  • ifRequired – a session will be created only if required (default),如果需要就創建(默認)。
  • never – the framework will never create a session itself but it will use one if it already exists
  • stateless – no session will be created or used by Spring Security 不創建不使用session

由於前后端不通過保存session和cookie來進行判斷,所以為了保證spring security能夠記錄登錄狀態,所以需要傳遞一個值,讓這個值能夠自我驗證來源,同時能夠得到數據信息。選型我們選擇JWT。對於java客戶端我們選擇使用jjwt

添加依賴

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>

創建工具類JWTProvider

JWTProvider需要至少提供兩個方法,一個用來創建我們的token,另一個根據token獲取Authentication。

provider需要保證Key密鑰是唯一的,使用init()構建,否則會拋出異常。

@Component
@Slf4j
public class JWTProvider {
    private Key key;	// 私鑰
    private long tokenValidityInMilliseconds; // 有效時間
    private long tokenValidityInMillisecondsForRememberMe; // 記住我有效時間
    @Autowired
    private JJWTProperties jjwtProperties; // jwt配置參數
    @Autowired
    private UserRepository userRepository; 
    @PostConstruct
    public void init() {
        byte[] keyBytes;
        String secret = jjwtProperties.getSecret();
        if (StringUtils.hasText(secret)) {
            log.warn("Warning: the JWT key used is not Base64-encoded. " +
                    "We recommend using the `jhipster.security.authentication.jwt.base64-secret` key for optimum security.");
            keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        } else {
            log.debug("Using a Base64-encoded JWT secret key");
            keyBytes = Decoders.BASE64.decode(jjwtProperties.getBase64Secret());
        }
        this.key = Keys.hmacShaKeyFor(keyBytes); // 使用mac-sha算法的密鑰
        this.tokenValidityInMilliseconds =
                1000 * jjwtProperties.getTokenValidityInSeconds();
        this.tokenValidityInMillisecondsForRememberMe =
                1000 * jjwtProperties.getTokenValidityInSecondsForRememberMe();
    }
    public String createToken(Authentication authentication, boolean rememberMe) {
        long now = (new Date()).getTime();
        Date validity;
        if (rememberMe) {
            validity = new Date(now + this.tokenValidityInMillisecondsForRememberMe);
        } else {
            validity = new Date(now + this.tokenValidityInMilliseconds);
        }
        User user = userRepository.findOneByLogin(authentication.getName());
        Map<String ,Object> map = new HashMap<>();
        map.put("sub",authentication.getName());
        map.put("user",user);
        return Jwts.builder()
                .setClaims(map) // 添加body
                .signWith(key, SignatureAlgorithm.HS512) // 指定摘要算法
                .setExpiration(validity) // 設置有效時間
                .compact();
    }
    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token).getBody(); // 根據token獲取body
        User principal;
        Collection<? extends GrantedAuthority> authorities;
        principal = userRepository.findOneByLogin(claims.getSubject());
        authorities = principal.getAuthorities();
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }
}

注意這里我們創建的User需要實現UserDetails對象,這樣我們可以根據principal.getAuthorities()獲取到權限,如果不實現UserDetails,那么需要自定義authorities並添加到UsernamePasswordAuthenticationToken中。

@Data
@Entity
@Table(name="user")
public class User implements UserDetails {
    @Id
    @Column
    private Long id;
    @Column
    private String login;
    @Column
    private String password;
    @Column
    private String role;
    @Override
    // 獲取權限,這里就用簡單的方法
    // 在spring security中,Authorities既可以是ROLE也可以是Authorities
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(role));
    }
    @Override
    public String getUsername() {
        return login;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return false;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
}

創建登錄成功,登出成功處理器

登錄成功后向前台發送jwt。

認證成功,返回jwt:

public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler{
    void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException{
        PrintWriter writer = response.getWriter();
        writer.println(jwtProvider.createToken(authentication, true));
    }
}

登出成功:

public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
    void onLogoutSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3) throws IOException, ServletException{
        PrintWriter writer = response.getWriter();
        writer.println("logout success");
        writer.flush();
    }
}

設置登錄、登出、取消csrf防護

登出無法對token進行失效操作,可以使用數據庫保存token,然后在登出時刪除該token。

@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    // code...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
       http
           // code...
           // 添加登錄處理器
           .formLogin().loginProcessingUrl("/login").successHandler((request, response, authentication) -> {
           PrintWriter writer = response.getWriter();
           writer.println(jwtProvider.createToken(authentication, true));
       })
           // 取消csrf防護
           .and().csrf().disable() 
           // code...
           // 添加登出處理器
           .and().logout().logoutUrl("/logout").logoutSuccessHandler((HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
           PrintWriter writer = response.getWriter();
           writer.println("logout success");
           writer.flush();
       })
        	// code...
    }
    // code...
}

使用JWT集成spring-security

添加Filter供spring-security解析token,並向securityContext中添加我們的用戶信息。

在UsernamePasswordAuthenticationFilter.class之前我們需要執行根據token添加authentication。關鍵方法是從jwt中獲取authentication,然后添加到securityContext中。

在SecurityConfiguration中需要設置Filter添加的位置。

創建自定義Filter,用於jwt獲取authentication:

@Slf4j
public class JWTFilter extends GenericFilterBean {

    private final static String HEADER_AUTH_NAME = "auth";

    private JWTProvider jwtProvider;

    public JWTFilter(JWTProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
            String authToken = httpServletRequest.getHeader(HEADER_AUTH_NAME);
            if (StringUtils.hasText(authToken)) {
                // 從自定義tokenProvider中解析用戶
                Authentication authentication = this.jwtProvider.getAuthentication(authToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            // 調用后續的Filter,如果上面的代碼邏輯未能復原“session”,SecurityContext中沒有想過信息,后面的流程會檢測出"需要登錄"
            filterChain.doFilter(servletRequest, servletResponse);
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }
}

向HttpSecurity添加Filter和設置Filter位置:

public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    // code...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
            	//設置添加Filter和位置
                .and().addFilterBefore(new JWTFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
                // code...
    }
    // code...
}

MySecurityConfiguration代碼

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JWTProvider jwtProvider;
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)// 設置自定義的userDetailsService
                .passwordEncoder(passwordEncoder());
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)//設置無狀態
                .and()
                .authorizeRequests() // 配置請求權限
                .antMatchers("/product/**").hasRole("USER") // 需要角色
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated() // 所有的請求都需要登錄
                .and()
            	// 配置登錄url,和登錄成功處理器
                .formLogin().loginProcessingUrl("/login").successHandler((request, response, authentication) -> {
                    PrintWriter writer = response.getWriter();
                    writer.println(jwtProvider.createToken(authentication, true));
                })
            	// 取消csrf防護
                .and().csrf().disable() 
                .httpBasic()
            	// 配置登出url,和登出成功處理器
				.and().logout().logoutUrl("/logout")
            	.logoutSuccessHandler((HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
                    PrintWriter writer = response.getWriter();
                    writer.println("logout success");
                    writer.flush();
                })
            	// 在UsernamePasswordAuthenticationFilter之前執行我們添加的JWTFilter
                .and().addFilterBefore(new JWTFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    @Override
    public void configure(WebSecurity web) {
        // 添加不做權限的URL
        web.ignoring()
                .antMatchers("/swagger-resources/**")
                .antMatchers("/swagger-ui.html")
                .antMatchers("/webjars/**")
                .antMatchers("/v2/**")
                .antMatchers("/h2-console/**");
    }
}

使用注解對方法進行權限管理

需要在MySecurityConfiguration上添加@EnableGlobalMethodSecurity(prePostEnabled = true)注解,prePostEnabled默認為false,需要設置為true后才能全局的注解權限控制。

prePostEnabled設置為true后,可以使用四個注解:

添加實體類School:

@Data
public class School implements Serializable {
    private Long id;
    private String name;
    private String address;
}
  • @PreAuthorize

    在訪問之前就進行權限判斷

    @RestController
    public class AnnoController {
        @Autowired
        private JWTProvider jwtProvider;
        @RequestMapping("/annotation")
    //    @PreAuthorize("hasRole('ADMIN')")
        @PreAuthorize("hasAuthority('ROLE_ADMIN')")
        public String info(){
            return "擁有admin權限";
        }
    }
    

    hasRole和hasAuthority都會對UserDetails中的getAuthorities進行判斷區別是hasRole會對字段加上ROLE_后再進行判斷,上例中使用了hasRole('ADMIN'),那么就會使用ROLE_ADMIN進行判斷,如果是hasAuthority('ADMIN'),那么就使用ADMIN進行判斷。

  • @PostAuthorize

    在請求之后進行判斷,如果返回值不滿足條件,會拋出異常,但是方法本身是已經執行過了的。

    @RequestMapping("/postAuthorize")
    @PreAuthorize("hasRole('ADMIN')")
    @PostAuthorize("returnObject.id%2==0")
    public School postAuthorize(Long id) {
        School school = new School();
        school.setId(id);
        return school;
    }
    

    returnObject是內置對象,引用的是方法的返回值。

    如果returnObject.id%2==0為 true,那么返回方法值。如果為false,會返回403 Forbidden。

  • @PreFilter

    在方法執行之前,用於過濾集合中的值。

    @RequestMapping("/preFilter")
    @PreAuthorize("hasRole('ADMIN')")
    @PreFilter("filterObject%2==0")
    public List<Long> preFilter(@RequestParam("ids") List<Long> ids) {
        return ids;
    }
    

    filterObject是內置對象,引用的是集合中的泛型類,如果有多個集合,需要指定filterTarget

    @PreFilter(filterTarget="ids", value="filterObject%2==0")
    public List<Long> preFilter(@RequestParam("ids") List<Long> ids,@RequestParam("ids") List<User> users,) {
        return ids;
    }
    

    filterObject%2==0會對集合中的值會進行過濾,為true的值會保留。

    第一個例子返回的值在執行前過濾返回2,4。

    image-20201202115120854

  • @PostFilter

    會對返回的集合進行過濾。

    @RequestMapping("/postFilter")
    @PreAuthorize("hasRole('ADMIN')")
    @PostFilter("filterObject.id%2==0")
    public List<School> postFilter() {
        List<School> schools = new ArrayList<School>();
        School school;
        for (int i = 0; i < 10; i++) {
            school = new School();
            school.setId((long)i);
            schools.add(school);
        }
        return schools;
    }
    

    上面的方法返回結果為:id為0,2,4,6,8的School對象。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM