Spring Security是 Spring 家族中的一個安全管理框架。相比與另外一個安全框架 Shiro ,它提供了更豐富的功能,社區資源也比Shiro豐富。
一般來說中大型的項目都是使用 SpringSecurity 來做安全框架。小項目有Shiro的比較多,因為相比與SpringSecurity,Shiro的上手更加的簡單。
一般Web應用的需要進行 認證 和 授權 ,而認證和授權也是SpringSecurity作為安全框架的核心功能。
- 認證:驗證當前訪問系統的是不是本系統的用戶,并且要確認具體是哪個用戶
- 授權:經過認證后判斷當前用戶是否有權限進行某個操作
2、登錄認證
在這里主要介紹了使用Spring Security進行認證操作,授權并不會在這篇文章中過多涉及(其實并沒有涉及)
一般來說,認證一般是用于登錄時的一種操作,因此下面為使用Spring Security進行登錄的一個簡易流程圖。
值得注意的是,這里會使用到 JWT(JSON Web Token) ,由于之前有對它進行一定的學習和記錄,這里就不過多贅述(雖然狗子我自己也有點忘記了得去看兩眼),有興趣的小伙伴可以自行前往《Spring Boot整合JWT》進行查看。
2.1、過濾器鏈
SpringSecurity的原理其實就是一個過濾器鏈,內部包含了提供各種功能的過濾器。而核心的過濾器主要為以下三個:
- UsernamePasswordAuthenticationFilter:負責處理我們在登陸頁面填寫了用戶名密碼后的登陸請求。
- ExceptionTranslationFilter:處理過濾器鏈中拋出的任何AccessDeniedException和AuthenticationException 。
- FilterSecurityInterceptor:負責權限校驗的過濾器。
同時我們也可以通過Debug查看當前系統中SpringSecurity過濾器鏈中有哪些過濾器及它們的順序。
2.2、認證流程
在認證過程中主要是以下接口發揮了重要作用:
- Authentication 接口: 它的實現類,表示當前訪問系統的用戶, 封裝了用戶相關信息 ;
- AuthenticationManager 接口: 定義了認證Authentication的方法 ;
- UserDetailsService 接口:加載用戶特定數據的核心接口。里面 定義了一個根據用戶名查詢用戶信息的方法 ;
- UserDetails 接口: 提供核心用戶信息 。通過UserDetailsService根據用戶名獲取處理的用戶信息要封裝成UserDetails對象返回,然后將這些信息封裝到Authentication對象中。
2.3、大思路分析
注注注注意!!!在這里我砍掉了視頻里的快速入門,那個覺得沒有太大必要記錄下來,當你完成第一步的時候隨便寫一個Hello接口就可以實現了,因此想了解的小伙伴直接過彎去視頻查看。
在過了一遍視頻和手敲了一遍之后,個人覺得很多地方還是很模糊,不太清晰,因此回過頭來分析一波整體的一個思路并且記錄在這里。
- 第一步,搭建基本環境。第一步我們肯定是得先搭建一個符合我們需求的一個Spring Boot項目對吧,在這里的話就需要搞定下面幾個家常便飯。
- 數據庫搭建和配置,這里包括了MySQL和redis兩個數據庫;
- SpringBoot項目搭建,導入對應需要的坐標,完成yml文件配置和MVC三層架構的搭建;
- 工具類、部分配置類編寫(其實這次也是一個合格的cv boy);
- 第二步,自定義實現UserDetailsService接口,取代掉原本的接口實現類。這一塊主要是讓我們自己輸入的賬號密碼能夠訪問我們自己的數據庫中進行查詢驗證,我們總不能還用著Spring Security給我們的賬號密碼對吧(框架中不進行任何操作的時候會有一個隨機的自生成的賬號密碼,沒啥用,就讓你香一下)
- 第三步,自定義登錄接口,取代掉原有的登錄接口。因為Spring Security自帶了一個登錄接口,但這個一般來說很難滿足我們開發過程中特定的需求,因此我們就需要重新搞一個登錄接口來謀權奪位。
3、擼袖開干
3.1、環境搭建
在環境搭建這一塊主要是以代碼塊為主,因為這一塊沒有涉及到很多的新知識(默認新知識只有Spring Security?),但是每一個代碼內或外都會有或多或少的解釋注明這一塊是用來干哈子的。
3.1.1、數據庫
1、MySQL數據庫搭建。因為這里只是用作學習Spring Security的,所以數據庫中一個表足以滿足它的欲望了,因此只有一個用戶表。
CREATE DATABASE IF NOT EXISTS db_security;
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`user_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '用戶名',
`nick_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '昵稱',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '密碼',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '賬號狀態(0正常 1停用)',
`email` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '郵箱',
`phonenumber` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手機號',
`sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用戶性別(0男,1女,2未知)',
`avatar` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '頭像',
`user_type` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '1' COMMENT '用戶類型(0管理員,1普通用戶)',
`create_by` bigint(0) NULL DEFAULT NULL COMMENT '創建人的用戶id',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '創建時間',
`update_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新時間',
`del_flag` int(0) NULL DEFAULT 0 COMMENT '刪除標志(0代表未刪除,1代表已刪除)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用戶表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'xbaozi', '陳寶子', '$2a$10$WCD7xp6lxrS.PvGmL86nhuFHMKJTc58Sh0dG1EQw0zSHjlLFyFvde', '0', NULL, NULL, NULL, NULL, '1', NULL, NULL, NULL, NULL, 0);
2、Redis搭建。如果是第一次使用Redis的話介紹太長了,雖然也有寫到Redis的安裝配置,但是沒有上傳上來還躺在我的電腦里,所以大家直接去搜一下就可以啦。
3.1.2、項目搭建
1、相關依賴導入。這里主要導入一些基本依賴和數據庫以及Spring Security涉及到的依賴。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.Apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>top.xbaoziplus</groupId>
<artifactId>security</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>security</name>
<description>Demo project for Spring Boot</description>
<properties>
<JAVA.version>1.8</java.version>
</properties>
<dependencies>
<!--SpringBoot Web服務-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--MyBatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!--MySQL數據庫-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--Lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--springboot單元測試-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--SpringSecurity啟動器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--SpringSecurity測試-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!--druid數據庫連接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.11</version>
</dependency>
<!--redis依賴-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--fastjson依賴-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.33</version>
</dependency>
<!--jwt依賴-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<!--解決Maven插件啟動報錯-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
</plugins>
</build>
</project>
2、yml配置文件。配置數據庫的基本連接信息。
# 指定端口號
server:
port: 8080
# 配置數據源
spring:
Application:
name: security
# 數據庫連接池配置
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/db_security?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: 123456
# Redis配置
redis:
# 這是我的虛擬機IP
host: 192.168.150.100
port: 6379
password: 123456
# 操作0號數據庫,默認有16個數據庫
database: 0
jedis:
pool:
max-active: 8 # 最大連接數
max-wait: 1ms # 連接池最大阻塞等待時間
max-idle: 4 # 連接池中的最大空閑連接
min-idle: 0 # 連接池中的最小空閑連接
cache:
redis:
time-to-live: 1800000 # 設置數據過期時間為半小時(ms)
3、實體類。因為數據庫只有一個表,因此我們只需要與之對應上就可以了。
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "sys_user")
@ToString
public class User implements Serializable {
private static final long serialVersionUID = 1L;
/** 主鍵 */
@TableId
private Long id;
/** 用戶名 */
private String userName;
/** 昵稱 */
private String nickName;
/** 密碼 */
private String password;
/** 賬號狀態(0正常 1停用) */
private String status;
/** 郵箱 */
private String email;
/** 手機號 */
private String phonenumber;
/** 用戶性別(0男,1女,2未知) */
private String sex;
/** 頭像 */
private String avatar;
/** 用戶類型(0管理員,1普通用戶) */
private String userType;
/** 創建人的用戶id */
private Long createBy;
/**
* 創建時間
*/
private Date createTime;
/** * 更新人 */
private Long updateBy;
/** * 更新時間 */
private Date updateTime;
/** 刪除標志(0代表未刪除,1代表已刪除) */
private Integer delFlag;
}
4、mapper編寫。因為這里使用的是MP,所以我們直接繼承BaseMapper就搞定了。
@Repository
public interface UserMapper extends BaseMapper<User> {
}
5、service編寫。分別編寫接口和實現類的代碼,這里還是MP的常規操作。
public interface UserService extends IService<User> {
}
------ 下面的在 impl 包里面 ------
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}
6、controller編寫。這里主要就是UserController和一個簡單的hello接口用作后續測試使用。
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello";
}
}
--- 兩個不一樣的文件 ---
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Autowired
private UserService loginService;
@PostMapping("/login")
public Result login(@RequestBody User user){
log.info("登錄的用戶為{}", user);
return loginService.login(user);
}
}
3.1.3、工具類、部分配置類
1、Redis配置類。主要是對Redis默認的序列化器進行一個更換。
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings(value = {
"unchecked", "rawtypes" })
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);
// 使用StringRedisSerializer來序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
2、JWT工具類。這里就不過多的介紹啦,有興趣的可以讀一下。需要注意的就是自己更換密鑰的時候,因為用到的是 Base64.getDecoder() ,只允許A~Z、 a~z、 0~9這些字符,如果需要用到特殊字符的話則需要換成 Base64.getMimeDecoder() 。(Ctrl+F 搜一下就知道在哪啦)
public class JwtUtil {
// 設置有效期為60 * 60 *1000 一個小時
public static final Long JWT_TTL = 60 * 60 * 1000L;
//設置秘鑰明文
public static final String JWT_KEY = "xbaozi";
public static String getUUID() {
String token = UUID.randomUUID().toString().replaceAll("-", "");
return token;
}
/**
* 生成jtw
* @param subject token中要存放的數據(json格式)
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 設置過期時間
return builder.compact();
}
/**
* 生成jwt
* @param subject token中要存放的數據(json格式)
* @param ttlMillis token超時時間
*/
public static String createJWT(String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 設置過期時間
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if (ttlMillis == null) {
ttlMillis = JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
.setSubject(subject) // 主題 可以是JSON數據
.setIssuer("sg") // 簽發者
.setIssuedAt(now) // 簽發時間
.signWith(signatureAlgorithm, secretKey) //使用HS256對稱加密算法簽名, 第二個參數為秘鑰
.setExpiration(expDate);
}
/**
* 創建token
*/
public static String createJWT(String id, String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 設置過期時間
return builder.compact();
}
/**
* 生成加密后的秘鑰 secretKey
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 解析
* @throws Exception
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
}
3、Redis工具類。這里主要是對Redis操作進行了進一步的封裝,簡化了操作和提高代碼重用,感興趣的同樣可以自行讀一下,畢竟狗子我也是cv之后再重新讀的?。
@SuppressWarnings(value = {
"unchecked", "rawtypes" })
@Component
public class RedisCache
{
@Autowired
public RedisTemplate redisTemplate;
/**
* 緩存基本的對象,Integer、String、實體類等
* @param key 緩存的鍵值
* @param value 緩存的值
*/
public <T> void setCacheObject(final String key, final T value)
{
redisTemplate.opsForValue().set(key, value);
}
/**
* 緩存基本的對象,Integer、String、實體類等
* @param key 緩存的鍵值
* @param value 緩存的值
* @param timeout 時間
* @param timeUnit 時間顆粒度
*/
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
{
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 設置有效時間
* @param key Redis鍵
* @param timeout 超時時間
* @return true=設置成功;false=設置失敗
*/
public boolean expire(final String key, final long timeout)
{
return expire(key, timeout, TimeUnit.SECONDS);
}
/**
* 設置有效時間
* @param key Redis鍵
* @param timeout 超時時間
* @param unit 時間單位
* @return true=設置成功;false=設置失敗
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit)
{
return redisTemplate.expire(key, timeout, unit);
}
/**
* 獲得緩存的基本對象。
* @param key 緩存鍵值
* @return 緩存鍵值對應的數據
*/
public <T> T getCacheObject(final String key)
{
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 刪除單個對象
* @param key
*/
public boolean deleteObject(final String key)
{
return redisTemplate.delete(key);
}
/**
* 刪除集合對象
* @param collection 多個對象
*/
public long deleteObject(final Collection collection)
{
return redisTemplate.delete(collection);
}
/**
* 緩存List數據
* @param key 緩存的鍵值
* @param dataList 待緩存的List數據
* @return 緩存的對象
*/
public <T> long setCacheList(final String key, final List<T> dataList)
{
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 獲得緩存的list對象
* @param key 緩存的鍵值
* @return 緩存鍵值對應的數據
*/
public <T> List<T> getCacheList(final String key)
{
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 緩存Set
* @param key 緩存鍵值
* @param dataSet 緩存的數據
* @return 緩存數據的對象
*/
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
{
BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext())
{
setOperation.add(it.next());
}
return setOperation;
}
/**
* 獲得緩存的set
* @param key
* @return
*/
public <T> Set<T> getCacheSet(final String key)
{
return redisTemplate.opsForSet().members(key);
}
/**
* 緩存Map
* @param key
* @param dataMap
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
{
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 獲得緩存的Map
* @param key
* @return
*/
public <T> Map<String, T> getCacheMap(final String key)
{
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入數據
* @param key Redis鍵
* @param hKey Hash鍵
* @param value 值
*/
public <T> void setCacheMapValue(final String key, final String hKey, final T value)
{
redisTemplate.opsForHash().put(key, hKey, value);
}
/**
* 獲取Hash中的數據
* @param key Redis鍵
* @param hKey Hash鍵
* @return Hash中的對象
*/
public <T> T getCacheMapValue(final String key, final String hKey)
{
HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
return opsForHash.get(key, hKey);
}
/**
* 刪除Hash中的數據
* @param key
* @param hkey
*/
public void delCacheMapValue(final String key, final String hkey)
{
HashOperations hashOperations = redisTemplate.opsForHash();
hashOperations.delete(key, hkey);
}
/**
* 獲取多個Hash中的數據
* @param key Redis鍵
* @param hKeys Hash鍵集合
* @return Hash對象集合
*/
public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
{
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 獲得緩存的基本對象列表
* @param pattern 字符串前綴
* @return 對象列表
*/
public Collection<String> keys(final String pattern)
{
return redisTemplate.keys(pattern);
}
}
4、fastjson對Redis工具類的配置。這里有一個問題就是不要使用高版本的fastjson依賴,因為高版本的好像是已經將
ParserConfig.getGlobalInstance().setAutoTypeSupport(true); 去除了,從而后面導致報錯。
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Class<T> clazz;
static
{
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJsonRedisSerializer(Class<T> clazz)
{
super();
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException
{
if (t == null)
{
return new byte[0];
}
return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
@Override
public T deserialize(byte[] bytes) throws SerializationException
{
if (bytes == null || bytes.length <= 0)
{
return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseobject(str, clazz);
}
protected JavaType getJavaType(Class<?> clazz)
{
return TypeFactory.defaultInstance().constructType(clazz);
}
}
到這里問題不大的話應該你的idea就是下面圖片里面的結構啦(因為我是已經敲完代碼再做的筆記,所以空的很多地方是因為我蓋住了)
3.2、實現UserDetailsService接口
這個是和視頻中的順序是一樣的,原本是在考慮著按照流程圖來先寫登錄接口再寫這一塊比對賬號密碼細節的,但是最終還是決定按照視頻的順序來。
因為最核心的其實就是在這一塊,在接口中有一個 loadUserByUsername 方法需要我們進行重寫,這個就是在數據庫中拿數據出來對比而不是使用Spring Security自生成的隨機的賬號密碼進行對比,可能也會更直觀一點。
3.2.1、具體實現
1、大致實現步驟
- 注入UserMapper。因為這里需要操作數據庫,因此我們需要引入登錄時需要的Mapper對數據進行操作;
- 使用 MP 獲取用戶數據,若查詢不到則拋出異常
- 注意的是 loadUserByUsername 方法返回的數據類型為 UserDetails ,因此需要將查詢到的數據 封裝 到該數據類型中
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根據用戶名查詢用戶信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
//如果查詢不到數據就通過拋出異常來給出提示
if(ObjectUtils.isEmpty(user)){
throw new RuntimeException("用戶名或密碼錯誤");
}
//封裝成UserDetails對象返回,其中LoginUser為UserDetails的實現類
return new LoginUser(user);
}
}
2、為實現 loadUserByUsername 方法實現 UserDetails 接口 。仔細翻看我們會發現UserDetails其實只是一個接口,因此我們需要自定義一個實現類來實現該接口從而達到我們的需求。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
/** 使用構造方法初始化 */
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
/** 下面的方法暫時全部都讓他們返回true */
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
3、初見成效。這時其實我們就可以利用自帶的登錄界面和接口,輸入賬號密碼和數據庫中數據對比進行登錄了。
但是如果要測試,并且如果想讓用戶的密碼是明文存儲,需要在密碼前加{noop},如密碼為 1234 ,那在數據庫中的數據就得為 {noop}1234 ,這與默認使用的PasswordEncoder有關。
3.2.2、密碼問題解決
通過上面的測試我們發現,這框架是個什么玩意,密碼都搞這么麻煩,而且我們實際項目中也不會將密碼明文存儲在數據庫中對吧,這要是讓哪個老六拿到手自己豈不是成大怨種了,因此我們要改進!
在Spring Security中默認使用的PasswordEncoder要求數據庫中的密碼格式為 {id}password ,框架會根據前面的id去判斷密碼的加密模式,我們上面的 {noop}1234 也是屬于這種格式,其中的 noop 就標明著這個密碼是以明文的形式進行存儲的,就會直接使用后面的 1234 當作密碼。
而一般我們會使用Spring Security為我們提供的 BCryptPasswordEncoder ,其操作也很簡單,我們只需要把 BCryptPasswordEncoder 對象注入Spring容器中計科,Spring Security就會使用我們自定義的 PasswordEncoder 進行密碼校驗。
那怎么將其加入到Spring容器中呢?我們可以定義一個SpringSecurity的配置類,SpringSecurity要求這個配置類要繼承
WebSecurityConfigurerAdapter。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 將BCryptPasswordEncoder加入到容器中
**/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
這里可以用測試類對 BCryptPasswordEncoder 進行測試,并且拿到我們密碼加密之后的密文,值得注意的是,即使是同一個密碼,兩次加密出來的密文很有可能并不是相同的,這是因為該加密方式會添加一個 隨機鹽 一起進行加密。
@Test
public void testBCryptPasswordEncoder() {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode("1234");
String encode2 = passwordEncoder.encode("123456");
boolean matches = passwordEncoder.matches("1234", "$2a$10$WCD7xp6lxrS.PvGmL86nhuFHMKJTc58Sh0dG1EQw0zSHjlLFyFvde");
System.out.println(encode);
System.out.println(encode2);
System.out.println(matches);
}
3.2.3、小疑問解決
問題所在:在編寫這一塊內容的時候其實是有一個疑問在腦子里面的,因為這里的方法是根據用戶名來進行查詢的,且只有一個方法,那么 如果用戶名重復了該怎么辦呢,這可是會拋異常的 。
問題思考結果:在查詢了許多前輩們的博文之后,雖然沒有找到很明確的答案,但是根據這些博文結合自己的思考得出了以下理解。即 loadUserByUsername方法 中的用戶名只是Spring Security的一個叫法,這其實就是 用戶的賬號 ,框架認為大家的賬號都是叫做 username 而已,大家的賬號總不能是相同的對吧,有些系統用的是手機號、有些系統用的是ID、有些系統用的是某些特殊標識,我們只需要在方法內部的查詢操作中對應上就行, 并不是 強制要求我們必須要有 username 這一字段或必須賬號就是 username 。
當然這只是一些 個人理解 ,如果有哪里出入,還請各位指出。
3.3、自定義登錄接口
在上一步測試的時候我們能夠很明顯的感受到Spring Security給我們自帶了一個出廠登錄接口,但是它一定是符合我們需求的接口嗎?所以我們就需要自定義一個自己的登錄接口。
3.3.1、編寫登錄接口。
編寫controller,定義登錄接口。
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Autowired
private UserService loginService;
@PostMapping("/login")
public Result<Map<String, String>> login(@RequestBody User user){
log.info("登錄的用戶為{}", user);
return loginService.login(user);
}
}
3.3.2、編寫登錄具體邏輯。
1、這一部分主要是在service層中去實現,這里的話主要是有以下幾個步驟。
- UserService中先定義好方法;
- 獲取認證入口 AuthenticationManager ;因為Spring Security中并沒有將 AuthenticationManager 加入到容器中,所以我們需要手動將其加到Spring容器中再進行注入。
- 通過 AuthenticationManager 的 authenticate 方法來進行用戶認證;authenticate 方法需要傳遞一個 Authentication 類型的參數,而該類型是一個 接口 ,因此使用該接口的實現類 UsernamePasswordAuthenticationToken 對用戶登錄信息進行 封裝 。
- 判斷是否驗證成功決定是否拋異常
- 在認證信息 authenticate 中獲取登錄成功后的用戶信息;通過debug調試我們會發現獲取的用戶信息都存放在 authenticate 的 Principal 中,因此我們只需要獲取該屬性然后進行強轉,就可以獲得需要的用戶數據了。
- 使用 userId 生成token;
- userId 用作 key ,將用戶信息存入redis;
- 把token響應給前端,我這里使用到了 Map 。
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
/** 獲取認證入口 */
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public Result<Map<String, String>> login(User user) {
// 在沒認證之前principal, credentials兩個參數分別存放用戶名和密碼
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
// 通過AuthenticationManager的authenticate方法來進行用戶認證
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
// 判斷是否驗證成功
if(Objects.isNull(authenticate)){
throw new RuntimeException("用戶名或密碼錯誤");
}
// 在認證信息authenticate中獲取登錄成功后的用戶信息
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
// 使用userid生成token
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
// userId用作key,將用戶信息存入redis,并設置30分鐘過期
redisCache.setCacheObject("login:" + userId, loginUser, 30, TimeUnit.MINUTES);
// 把token響應給前端
HashMap<String,String> map = new HashMap<>();
map.put("token",jwt);
return Result.success(map, "登錄成功");
}
}
2、將AuthenticationManager接入到Spring容器中。在上面service實現的時候有說到,Spring Security并沒有將 AuthenticationManager 加入到容器中,因此我們需要手動將其加到Spring容器中再進行注入,這里同樣借助SecurityConfig配置類重寫 authenticationManagerBean方法 生成Bean。
代碼在下面配置放行時一起,畢竟都是在同一個配置類中
3、放行登錄接口。大家可能會發現,當我們隨便進入一個接口的時候,如 /hello ,請求都會被攔截下來,這是因為Spring Security底層就是一條過濾器鏈,默認是對所有接口進行攔截,因此我們就需要讓框架對我們自定義的接口放行,讓用戶在不登錄的情況下也能夠訪問登錄接口。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/** 這里使用了數組當作下方可變參數列表的參數 */
private String[] matchers = new String[]{
"/user/login"
};
/**
* 將BCryptPasswordEncoder加入到容器中
**/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* 將認證入口AuthenticationManager注入容器中用于用戶認證
**/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 配置SpringSecurity對需要放行的接口放行
**/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//關閉csrf
.csrf().disable()
//不通過Session獲取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 對于登錄接口 允許匿名訪問,下面注釋的是視頻中的使用方式
//.antMatchers("/user/login").anonymous()
.antMatchers(matchers).anonymous()
// 除上面外的所有請求全部需要鑒權認證
.anyRequest().authenticated();
}
}
3.3.3、Bug跑起來。
這個時候我們就可以真正意義上使用自己的接口,調用自己的數據庫進行登錄操作啦,這里使用Postman進行測試(如果有自己的頁面可以加入前端界面進行協調)。
大家伙記得記得你們的redis要先跑起來!!!
3.4、認證過濾器
3.4.1、思路分析
為什么這里我們還需要一個認證過濾器在這里呢?
是因為前面我們實現的只是簡單的登錄操作,但是總會有一些大聰明企圖在未登錄的情況下訪問我們不給他訪問的東西,因此我們就需要編寫一個登錄狀態的一個認證攔截器對為登錄的用戶進行攔截,這就類似于初學過濾器寫的登錄攔截器中判斷session是否存在一樣。
該過濾器會去獲取 請求頭中的token ,對token進行解析取出其中的 userId 。使用userId去redis中獲取對應的LoginUser對象。然后封裝 Authentication 對象存入 SecurityContextHolder 。但是我們還需要思考一個問題,那就是Spring Security是存在自己的一套過濾器鏈的(可以回頭看目錄中的 2.1、過濾器鏈 ),那么我們這個過濾器應該放在過濾器鏈中的哪個位置呢?
答案是應該放在
UsernamePasswordAuthenticationFilter 的前面。這是因為我們的登錄接口是在這里被調用的,當過濾器鏈走到這里的時候就證明是開始獲取賬號密碼的時候了,很顯然我們目前只需要驗證用戶是否登錄,獲取賬戶密碼的意義不大,因此需要放到他的前面去。
3.4.2、具體實現
主要流程有如下幾步:
- 定義過濾器并將其 加入Spring容器 中,因為后面需要將其插入到過濾器鏈中;
- 獲取 請求頭中的token數據;
- 判斷請求頭中 是否攜帶token數據 ,若沒有攜帶有兩種可能:用戶需要登錄,正在訪問登錄接口準備賬號密碼登錄;用戶未登錄或登錄過期,導致無token或token已過期;
- 解析token 數據,從中拿到userId;
- 從 Redis 中獲取用戶數據,若Redis中無數據則證明登錄已過期,拋異常提示;
- 將用戶數據存入 SecurityContextHolder :因為這里是認證,是假設已經登錄之后的狀態,所以參數列表分別為用戶數據,空,鑒權信息;如果是前面的還未登錄狀態,參數列表則為賬號和密碼兩個參數;
- 將過濾器插入至過濾器鏈中。
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//獲取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
log.info("無token");
//放行
filterChain.doFilter(request, response);
return;
}
//解析token
String userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//從redis中獲取用戶信息
String redisKey = "login:" + userId;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("賬號登錄已超時,請重新登錄");
}
//存入SecurityContextHolder
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
這里對沒有獲取到token數據時為什么要放行的原因進行一個個人理解體會的記錄。
從 2.1、過濾器鏈 中的解釋我們可以看到主要作用的有三個過濾器(圖片已經貼到這里),其中可以簡單的理解為第一個用于登錄的、第二個用于異常捕獲的、第三個則是從 SecurityContextHolder 中獲取數據來鑒權。
這時我們應該可以大致的感受出來為什么可以放行而不是拋異常了, 放行是為了可能需要后面的登錄操作,不用擔心有老六偷溜繞過認證是因為有第三個過濾器的存在 。
因為如果是已登錄的狀態會在上面 自定義的過濾器 中將用戶信息(內包含鑒權信息)存放至 SecurityContextHolder 中去,如果在該過濾器中沒能成功從中拿到數據,那就證明該用戶這次操作并不是登錄操作,而是真正需要攔截的老六操作,因此就會在 FilterSecurityInterceptor過濾器 中拋出異常。
而加上 return 的目的是為了避免在過濾器鏈往回執行的時候執行自定義過濾器中的后面邏輯,因為這是無用操作。
到這里其實還沒有結束,因為我們還需要將自定義過濾器添加到過濾器鏈中,更準確的說是將自定義過濾器添加至
UsernamePasswordAuthenticationFilter過濾器的前面 ,當然這一操作Spring Security幫我們封裝好了對應的方法,要不然要這框架有何用!
- 我們直接在 SecurityConfig配置類 中將自定義過濾器注入,并在 configure方法 中將其插入到指定位置。
- 為了突出修改的地方,這里將部分前面已經配置的方法進行了隱藏去除。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 配置SpringSecurity對需要放行的接口放行
**/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//關閉csrf
.csrf().disable()
//不通過Session獲取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 對于登錄接口 允許匿名訪問,其中的antMatchers可以傳遞一個數組
.antMatchers("/user/login").anonymous()
// 除上面外的所有請求全部需要鑒權認證
.anyRequest().authenticated();
// 參數列表分別為需要插入的過濾器和標識過濾器的字節碼
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
3.4.3、試當老六
首先我們在不登錄的情況下訪問 /hello 接口,可以發現是訪問不了的
然后我們再嘗試一下登錄生成token,將token數據加入到請求頭中之后再進行訪問。(token錯誤的我就不演示了哈,感興趣的小伙伴自己可以測試一下)
3.5、退出登錄
既然登錄都搞了,登出怎么說也得來一手搞一條龍服務對吧。
主要步驟如下:
- 從SecurityContextHolder中獲取認證信息。因為在訪問退出接口的時候,肯定是已經登錄了且是經過了自定義的過濾器,因此在SecurityContextHolder中是已經存放了該登錄用戶的基本數據信息,這樣我們就是可以獲取得到的。
- 根據獲取到的用戶數據獲取useId進行key的拼接,并從Redis中刪除指定key的值,即刪除該用戶已登錄的標識
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private RedisCache redisCache;
@Override
public Result<String> logout() {
// 從SecurityContextHolder中獲取認證信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 判空,避免登錄已過期導致異常
if (ObjectUtils.isEmpty(authentication)) {
return Result.fail("登錄已過期,退出登錄失敗");
}
// 從認證信息中獲取登錄用戶數據
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 從登錄用戶數據中獲取userId
Long userId = loginUser.getUser().getId();
// 從redis中將該用戶的值刪除
redisCache.deleteObject("login:" + userId);
return Result.success("退出登錄成功");
}
}
4、見證奇跡
到這里其實就已經是這一條龍服務已經搞完啦,當我們去測試的時候可以完整的看到從登錄到退出都是可以正常運行的,下面就是奇跡的時刻,居然沒有bug!
- 使用賬號密碼訪問 /user/login 進行登錄
- 攜帶token數據訪問 /hello 接口
- 攜帶token數據訪問 /user/logout 接口。注意這里是 需要帶上token 的,這是因為訪問該接口的時候還是要經過自定義過濾器進行驗證用戶登錄狀態設置SecurityContextHolder的值,否則就無法知道是哪個用戶需要進行退出操作了。
- 退出登錄之后再訪問 /hello 接口,即使token還在,但是redis中已經沒有了對應的值,這就表示著這個token已經失去了有效性。