Spring Boot之整合Spring Security: 訪問認證

前言

在過往的一些Spring Boot學習項目中,我們會發現,我們開發的API都不需要認證,對所有人開放,連登錄都不需要,毫無安全可言。
在項目實戰中往往需要做好認證、授權、攻擊防護,Spring Boot在這方面也提供了快速解決方案,即:推薦使用Spring Security

  • Spring Boot為Spring Security提供了自動化配置方案,可零配置使用 Spring Security。

項目代碼已上傳Git Hub,歡迎取閱:

簡單入門

1. 添加依賴;

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

2. 編寫Controller;

package com.github.dylanz666.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author : dylanz
 * @since : 08/30/2020
 */
@RestController
public class HelloController {
    @GetMapping("/hello")
    public String sayHello() throws Exception {
        return "Hello!";
    }
}

3. 啟動項目并訪問API;

  • 啟動項目:

注意一條log:Using generated security password: e10ac5ca-d3ab-4f0e-8e25-cbcf6afce611,下文會使用到。

啟動項目
  • 在瀏覽器中訪問API:

API如:http://127.0.0.1:8080/hello

默認登錄頁面
訪問API
  • 輸入用戶名密碼登錄:
    1). Username: 默認用戶名為user;
    2). Password: 默認密碼為log中打印的密碼,e10ac5ca-d3ab-4f0e-8e25-cbcf6afce611;
    戶名密碼登錄
我們一起來分析一下:

1). 未登錄時訪問API會重定向到登錄頁面:http://127.0.0.1:8080/login
2). Spring Security為我們提供了默認的登錄頁面,登錄頁面還算美觀;
3). 登錄后,后續的請求中,會在請求頭中帶上含有JESSIONID的Cookie;

Cookie

可在項目application.properties中提前配置好用戶名和密碼,如:

server.port=8080
spring.security.user.name=dylanz
spring.security.user.password=666
用戶名密碼登錄

至此,我們就實現了最簡單的登錄認證。


自定義登錄頁面實例

  • 未登錄狀態下API請求重定向到登錄頁面還是比較奇怪的,一般來說,API未登錄狀態下的請求應該顯示狀態碼:401
  • 通常情況下,應該是進入某個有訪問限制的頁面,當未登錄時,重定向到登錄頁面;

因此,我們將場景變為:

我們將采用視圖技術,簡單做個案例。Spring Boot框架內使用視圖技術可參考:

thymeleaf使用準備:

1). 添加thymeleaf依賴;
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
2). 修改配置文件;
server.port=8080
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8

1. 定義主頁home.html;

  • 在resources下創建templates文件夾,并創建home.html文件:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>

<p>Click <a th:href="@{/hello.html}">here</a> to see a greeting.</p>
</body>
</html>
  • 前往hello.html頁面的代碼:<a th:href="@{/hello.html}">here</a>

2. 定義hello.html頁面;

  • 在templates文件夾下創建hello.html文件:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
    <input type="submit" value="Sign Out"/>
</form>
</body>
</html>
  • hello.html頁面上提供一個登出入口"Sign Out";

3. 自定義login/logout頁面;

  • 在templates文件夾下創建login.html文件:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <title>Spring Security Example </title>
</head>
<body>
<div th:if="${param.error}">
    Invalid username and password.
</div>
<div th:if="${param.logout}">
    You have been logged out.
</div>
<form th:action="@{/login}" method="post">
    <div><label th:style="'background:red;'"> User Name: <input type="text" name="username"/> </label></div>
    <div><label th:style="'background:red;'"> Password: <input type="password" name="password"/> </label></div>
    <div><input type="submit" value="Sign In"/></div>
</form>
</body>
</html>
  • 當用戶名密碼錯誤時提示信息:Invalid username and password.
  • 當登出時提示信息:You have been logged out.
  • 為了演示自定義頁面,我還特地改了下頁面元素樣式,把User Name和Password label的背景色改為紅色:th:style="'background:red;'"
    (筆者沒有花過多的時間處理樣式哈,此處只做簡單演示)

4. 組織頁面行為;

1). 配置模板匹配規則;

目的是使網站的url指向具體視圖,而不是當作API來訪問;
在項目下創建config包,并在config包內創建WebMvcConfig類,編寫WebMvcConfig類如下:

package com.github.dylanz666.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author : dylanz
 * @since : 08/30/2020
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/home.html").setViewName("home");
        registry.addViewController("/").setViewName("home");
        registry.addViewController("/hello.html").setViewName("hello");
        registry.addViewController("/login.html").setViewName("login");
    }
}
  • 訪問/和/home.html路徑時,使用模板:home.html
  • 訪問/hello.html路徑時,使用模板:hello.html
  • 訪問/login.html路徑時,使用模板:login.html
2). 頁面訪問權限設置;

在config包下創建類:WebSecurityConfig,編寫類如下:

package com.github.dylanz666.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.provisioning.InMemoryUserDetailsManager;

/**
 * @author : dylanz
 * @since : 08/30/2020
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                .antMatchers("/", "/home.html").permitAll()//這2個url不用訪問認證
                .anyRequest().authenticated()//其他url都需要訪問認證
                .and()
                .formLogin()
                .loginPage("/login.html")//登錄頁面的url
                .loginProcessingUrl("/login")//登錄表使用的API
                .permitAll()//login.html和login不需要訪問認證
                .and()
                .logout()
                .permitAll();//logout不需要訪問認證
    }
}

幾點解釋:

  • @EnableWebSecurity:官網說這是為了開啟Web Security支持,并提供Spring MVC集成,具體咋回事咱也不知道呀,跟著用就是對了!
  • .antMatchers("/", "/home.html").permitAll():配置不需要認證的url,也即任何人都可以訪問的url;
  • .loginPage("/login.html"):配置登錄頁面的url,由于我們自定義了登錄頁面,因此需使用這個配置,如果不是用此配置,則使用Spring Security提供的默認登錄頁面;
  • .loginProcessingUrl("/login"): 配置登錄表單使用的API,Spring Security默認提供"/login"接口,用于登錄驗證;

3). 啟動項目查看效果;

  • 訪問主頁:http://127.0.0.1:8080/

訪問主頁
  • 點擊頁面中的"here"鏈接;

點擊鏈接

此時嘗試訪問http://127.0.0.1:8080/hello.html,但由于我們沒有登錄,因此Spring Security自動幫我們跳轉到登錄頁面:http://127.0.0.1:8080/login.html

  • 登錄;

登錄

登錄后
  • 登錄后訪問項目寫好的API;

筆者在項目中的controller包中寫了個HelloController類,類中寫了個get類型的API,代碼如下:

package com.github.dylanz666.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author : dylanz
 * @since : 08/30/2020
 */
@RestController
public class HelloController {
    @GetMapping("/hello")
    public String sayHello() throws Exception {
        return "Hello!";
    }
}

此時在瀏覽器中直接訪問API:http://127.0.0.1:8080/hello

登錄訪問API

  • 登出;

點擊hello.html頁面上的"Sign Out"按鈕登出;


登出

此時退出到登錄頁面,且頁面有提示信息:You have been logged out.

  • 登出后訪問項目寫好的API;

再次在瀏覽器中直接訪問API:http://127.0.0.1:8080/hello
此時我們會發現API被重定向到登錄頁面了;

登出訪問API

通過本案例,我們學會了如何使用Spring Security進行基本的訪問限制和自定義登錄頁面。


用戶管理;

用戶管理有幾種方式:

1. 在resources底下的application.properties內配置可登錄的用戶信息:

spring.security.user.name=dylanz
spring.security.user.password=666
這種方式有個弊端:只能配置一個用戶信息;

2. 在config底下的WebSecurityConfig配置類內添加可登錄的用戶信息userDetailsService,如:

package com.github.dylanz666.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.provisioning.InMemoryUserDetailsManager;

/**
 * @author : dylanz
 * @since : 08/30/2020
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                .antMatchers("/", "/home.html").permitAll()//這2個url不用訪問認證
                .anyRequest().authenticated()//其他url都需要訪問認證
                .and()
                .formLogin()
                .loginPage("/login.html")//登錄頁面的url
                .loginProcessingUrl("/login")//登錄表使用的API
                .permitAll()//login.html和login不需要訪問認證
                .and()
                .logout()
                .permitAll();//logout不需要訪問認證
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        UserDetails dylanz =
                User.withUsername("dylanz")
                        .password(bCryptPasswordEncoder.encode("666"))
                        .roles("ADMIN")
                        .build();
        return new InMemoryUserDetailsManager(user);
    }
}

3. WebSecurityConfig配置類內可配置多個可登錄的用戶信息:

package com.github.dylanz666.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.provisioning.InMemoryUserDetailsManager;

/**
 * @author : dylanz
 * @since : 08/30/2020
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                .antMatchers("/", "/home.html").permitAll()//這2個url不用訪問認證
                .anyRequest().authenticated()//其他url都需要訪問認證
                .and()
                .formLogin()
                .loginPage("/login.html")//登錄頁面的url
                .loginProcessingUrl("/login")//登錄表使用的API
                .permitAll()//login.html和login不需要訪問認證
                .and()
                .logout()
                .permitAll();//logout不需要訪問認證
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        UserDetails dylanz =
                User.withUsername("dylanz")
                        .password(bCryptPasswordEncoder.encode("666"))
                        .roles("ADMIN")
                        .build();
        UserDetails ritay =
                User.withUsername("ritay")
                        .password(bCryptPasswordEncoder.encode("888"))
                        .roles("USER")
                        .build();
        UserDetails jonathanw =
                User.withUsername("jonathanw")
                        .password(bCryptPasswordEncoder.encode("999"))
                        .roles("USER")
                        .build();
        return new InMemoryUserDetailsManager(dylanz, ritay, jonathanw);
    }
}
我在WebSecurityConfig配置類內設置了3個可登錄的用戶,我們可以通過這種方式相對靈活的添加N個用戶。

4. 在數據庫中保存可登錄的用戶信息:

這是更常見的保存用戶信息的方式,我們仍以最簡單的方式來Demo從中心化的用戶信息池獲取用戶信息,即:模擬數據庫查詢過程;
1). 項目下創建domain包、service包;
2). domain包內創建User實體類、service包下創建UserDetailsImpl類和UserDetailsServiceImpl類;

創建類

3). 編寫User實體類;

package com.github.dylanz666.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Component;

import java.io.Serializable;

/**
 * @author : dylanz
 * @since : 08/31/2020
 */
@NoArgsConstructor
@AllArgsConstructor
@Data
@Component
public class User implements Serializable {
    private static final long serialVersionUID = 1L;

    private String username;
    private String password;
}

4). 編寫UserDetailsImpl類;

package com.github.dylanz666.service;

import com.github.dylanz666.domain.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collection;

/**
 * @author : dylanz
 * @since : 08/31/2020
 */
@Service
public class UserDetailsImpl implements UserDetails {
    private User currentUser;

    public UserDetailsImpl() {
    }

    public UserDetailsImpl(User user) {
        if (user != null) {
            this.currentUser = user;
        }
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        SimpleGrantedAuthority authority = new SimpleGrantedAuthority("admin");
        authorities.add(authority);
        return authorities;
    }

    @Override
    public String getPassword() {
        return currentUser.getPassword();
    }

    public String getUsername() {
        return currentUser.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

5). 編寫UserDetailsServiceImpl類;

package com.github.dylanz666.service;

import com.github.dylanz666.domain.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
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 org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @author : dylanz
 * @since : 08/31/2020
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private UserDetailsImpl userService;
    @Autowired
    private UserDetails userDetails;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //Spring Security要求必須加密密碼
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        //模擬從數據庫中取出用戶信息,使用的sql如: SELECT * FROM USER WHERE USER_NAME='cherrys'
        List<User> userList = new ArrayList<>();
        User firstUser = new User();
        firstUser.setUsername("cherrys");
        firstUser.setPassword(passwordEncoder.encode("123"));
        userList.add(firstUser);
        User secondUser = new User();
        secondUser.setUsername("randyh");
        secondUser.setPassword(passwordEncoder.encode("456"));
        userList.add(secondUser);

        List<User> mappedUsers = userList.stream().filter(s -> s.getUsername().equals(username)).collect(Collectors.toList());

        //判斷用戶是否存在
        User user;
        if (CollectionUtils.isEmpty(mappedUsers)) {
            logger.info(String.format("The user %s is not found !", username));
            throw new UsernameNotFoundException(String.format("The user %s is not found !", username));
        }
        user = mappedUsers.get(0);
        return new UserDetailsImpl(user);
    }
}

解釋一下:

  • UserDetailsServiceImpl: 用于模擬從數據庫查詢出用戶信息,且模擬數據庫中存儲了加密的字符串;

  • UserDetailsImpl:用于使用從數據庫查詢出的用戶信息,設置可登錄的用戶名、密碼,設置過程要配合使用WebSecurityConfig;

6). 修改WebSecurityConfig配置類;

package com.github.dylanz666.config;

import com.github.dylanz666.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

/**
 * @author : dylanz
 * @since : 08/30/2020
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                .antMatchers("/", "/home.html").permitAll()//這2個url不用訪問認證
                .anyRequest().authenticated()//其他url都需要訪問認證
                .and()
                .formLogin()
                .loginPage("/login.html")//登錄頁面的url
                .loginProcessingUrl("/login")//登錄表使用的API
                .permitAll()//login.html和login不需要訪問認證
                .and()
                .logout()
                .permitAll();//logout不需要訪問認證
        httpSecurity.userDetailsService(userDetailsService());
        httpSecurity.userDetailsService(userDetailsService);
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        UserDetails dylanz =
                User.withUsername("dylanz")
                        .password(bCryptPasswordEncoder.encode("666"))
                        .roles("ADMIN")
                        .build();
        UserDetails ritay =
                User.withUsername("ritay")
                        .password(bCryptPasswordEncoder.encode("888"))
                        .roles("USER")
                        .build();
        UserDetails jonathanw =
                User.withUsername("jonathanw")
                        .password(bCryptPasswordEncoder.encode("999"))
                        .roles("USER")
                        .build();
        return new InMemoryUserDetailsManager(dylanz, ritay, jonathanw);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

特別注意:

  • 必須在WebSecurityConfig中聲明PasswordEncoder;

  • 在WebSecurityConfig的configure方法中使用:

httpSecurity.userDetailsService(userDetailsService);

至此,我們在內存中添加了dylanz,ritay,jonathanw三個用戶,并且數據庫中也存儲了cherrys、randyh兩個用戶,一共5個用戶;

我們來測試一下:

randyh+正確密碼1
randyh+正確密碼2
randyh+錯誤密碼
dylanz+正確密碼1
dylanz+正確密碼2
dylanz+錯誤密碼
不存在的賬戶

這個認證過程還是比較初級的,真實案例中會比這個認證過程復雜許多,我們開卷有益,再接再厲!


如果本文對您有幫助,麻煩動動手指點點贊?

謝謝!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容