SpringBoot實戰:Spring Boot接入Security權限認證服務
引言
Spring Security 是一個功能強大且高度可定制的身份驗證和訪問控制的框架,提供了完善的認證機制和方法級的授權功能,是一個非常優秀的權限管理框架。其核心是一組過濾器鏈,不同的功能經由不同的過濾器。本文將通過一個案例將 Spring Security 整合到 SpringBoot中,要實現的功能就是在認證服務器上登錄,然后獲取Token,再訪問資源服務器中的資源。
一、基本介紹
登錄驗證:
通過 JWT 為每個用戶生成一個唯一且有期限的 Token,用戶每次請求都會重新生成過期時間,在規定的時間內,用戶未進行操作 Token 就會過期,當用戶再次請求時則會再次執行登錄流程,而 Token 的過期時間應根據實際的業務場景規定。
權限認證:
權限認證通過Spring Security框架來實現,在用戶成功登錄之后,當嘗試訪問系統資源時(即發起接口調用),服務端會根據用戶所屬的角色來判斷其是否具備相應的訪問權限。若用戶未獲得該資源的訪問權限,則服務端應當返回明確的權限不足提示信息,以確保系統的安全性與用戶體驗。
通過如圖來講解我們的實現目標:登錄驗證 和 權限認證

二、環境準備
創建 auth_user 系統用戶表,并準備測試數據。
CREATE TABLE `auth_user`
(
`id` varchar(36) NOT NULL,
`username` varchar(100) DEFAULT NULL,
`password` varchar(100) DEFAULT NULL,
`role` varchar(100) DEFAULT NULL,
`account_non_expired` int(11) DEFAULT '0',
`account_non_locked` int(11) DEFAULT '0',
`credentials_non_expired` int(11) DEFAULT '0',
`is_enabled` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf32;
INSERT INTO auth_user (id, username, password, `role`, account_non_expired, account_non_locked,
credentials_non_expired, is_enabled)
VALUES ('1', 'user', '15tT+y0b+lJq2HIKUjsvvg==', 'USER', 1, 1, 1, 1),
('2', 'admin', '15tT+y0b+lJq2HIKUjsvvg==', 'ADMIN', 1, 1, 1, 1);
三、登錄代碼實現
1.為項目導入相關依賴
在pom.xml 文件中到入依賴,除了 Security 之外 還引入了 AES 和 JWT相關依賴
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- AES加密 -->
<dependency>
<groupId>org.apache.directory.studio</groupId>
<artifactId>org.apache.commons.codec</artifactId>
<version>1.8</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
</dependencies>
創建項目所需實體類:
在工程中創建一個新的實體類AuthUser,該實體類需要實現Spring Security的UserDetails接口,并特別地,需要重寫getAuthorities()方法來從數據庫中動態讀取并設置用戶的角色權限。此外,為了確保用戶賬戶處于正常激活狀態,isAccountNonExpired()、isAccountNonLocked()、isCredentialsNonExpired()、isEnabled()這四個方法也必須被重寫,并且應該基于數據庫查詢的結果或業務邏輯,無條件地返回true(假設在這個場景下,所有用戶賬戶都被視為有效、未過期、未鎖定且憑據未過期)。
這樣的設計確保了AuthUser類能夠準確地反映用戶的安全狀態和權限信息,同時允許Spring Security框架利用這些信息進行訪問控制。通過從數據庫動態加載權限信息,系統能夠靈活地適應不同用戶的權限需求,提升系統的安全性和靈活性。
public class AuthUser implements Serializable, UserDetails {
private static final long serialVersionUID = 1L;
private String id;
private String username;
private String password;
private String role;
private Integer accountNonExpired;
private Integer accountNonLocked;
private Integer credentialsNonExpired;
private Integer isEnabled;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 獲取用戶所有權限
String[] roles = role.split(",");
// 遍歷 roles,取出每一個權限進行認證,添加到簡單的授予認證類
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
// 返回到已經被授予認證的權限集合, 這里面的角色所擁有的權限都已經被 spring security 所知道
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired != null && this.accountNonExpired == 1;
}
@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked != null && this.accountNonLocked == 1;
}
@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired != null && this.credentialsNonExpired == 1;
}
@Override
public boolean isEnabled() {
return this.isEnabled != null && this.isEnabled == 1;
}
// 略去其它 Get、Set 方法
}
創建 Service 服務
創建名為 AuthUserService 的接口,并實現 UserDetailsService 類,重寫 loadUserByUsername() 方法( Security 認證登錄調用的接口)。
public interface AuthUserService extends UserDetailsService {
}
@Service("authUserService")
public class AuthUserServiceImpl implements AuthUserService {
@Resource
private AuthUserDao authUserDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AuthUser authUser = authUserDao.queryByName(username);
if (authUser == null) {
throw new IllegalArgumentException("User [" + username + "] doesn't exist.");
}
return authUser;
}
}
AutUserDao 是用來解讀數據庫信息的類, queryByName() 是通過 username 從 auth_user 數據表進行精準查詢。
Congtroller 層方法
創建兩個接口分別供不同角色測試。
@RestController
@RequestMapping("api/resource")
public class ResourceController {
@GetMapping("user")
public String demo1() {
return "User demo.";
}
@GetMapping("admin")
public String demo2() {
return "Admin demo.";
}
}
四、工具類
AES加密
在前后端數據傳輸過程中明文密碼傳輸存在相當大的隱患,可以采用加密的方式,對信息進行隱藏,話不多說上代碼。
public class AESUtil {
private final static String ALGORITHM = "AES/CBC/NoPadding";
private final static String DEFAULT_IV = "1234567890123456";
private final static String DEFAULT_KEY = "1234567890123456";
public static String encrypt(String data) throws Exception {
return encrypt(data, DEFAULT_KEY, DEFAULT_IV);
}
public static String desEncrypt(String data) throws Exception {
return desEncrypt(data, DEFAULT_KEY, DEFAULT_IV);
}
public static String encrypt(String data, String key, String iv) throws Exception {
Cipher cipher = Cipher.getInstance(ALGORITHM);
int blockSize = cipher.getBlockSize();
byte[] dataBytes = data.getBytes();
int length = dataBytes.length;
if (length % blockSize != 0) {
length = length + (blockSize - (length % blockSize));
}
byte[] plaintext = new byte[length];
System.arraycopy(dataBytes, 0, plaintext, 0, dataBytes.length);
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes());
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
byte[] encrypted = cipher.doFinal(plaintext);
return new Base64().encodeToString(encrypted);
}
public static String desEncrypt(String data, String key, String iv) throws Exception {
byte[] encrypted1 = new Base64().decode(data);
Cipher cipher = Cipher.getInstance(ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes());
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] bytes = cipher.doFinal(encrypted1);
return new String(bytes);
}
}
JWT生成
通過引入JWT(JSON Web Tokens),我們可以高效地管理用戶的登錄狀態。JWT能夠生成一串包含過期時間的Token值,該值以字符串形式存在。當Token達到其設定的過期時間時,嘗試對其進行解析將會觸發ExpiredJwtException異常。通過捕獲這個ExpiredJwtException異常,我們能夠有效地判斷用戶的登錄狀態是否已經過期。在上述描述中,createJWT()函數負責生成Token,而parseJWT()函數則負責解析Token。這樣的機制既方便了Token的生成與管理,也簡化了用戶登錄狀態的驗證過程。
public class TokenUtil {
/**
* 密鑰
*/
public static final String JWT_KEY = "ibudai";
/**
* 過期時間
*/
public static final Long JWT_TTL = TimeUnit.MINUTES.toMillis(5);
/**
* 生成 Token
*/
public static String createJWT(String data, Long ttlMillis) {
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
JwtBuilder builder = getJwtBuilder(data, ttlMillis, uuid);
return builder.compact();
}
/**
* 解析 Token
*/
public static Claims parseJWT(String token) {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
/**
* 生成加密后的秘鑰
*/
private static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JWT_KEY);
return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm algorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if (ttlMillis == null) {
ttlMillis = JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid)
// 計算內容
.setSubject(subject)
// 簽發者
.setIssuer("budai")
// 簽發時間
.setIssuedAt(now)
// 加密算法簽名
.signWith(algorithm, secretKey)
.setExpiration(expDate);
}
}
五、權限配置
接下來正式配置 Security 權限模塊。
新建SecurityConfig類,并使其繼承自WebSecurityConfigurerAdapter,隨后在該類中重寫configure(AuthenticationManagerBuilder auth)方法。在這個方法內部,我們將利用AuthUserService(即之前創建的用于從數據庫中讀取用戶角色數據的類)來配置用戶認證信息。這樣的配置確保了Spring Security能夠基于數據庫中存儲的用戶和角色信息來執行身份驗證。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthUserService authUserService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 動態讀取數據庫信息
auth.userDetailsService(authUserService)
// 自定義 AES 方式加密
.passwordEncoder(new AESEncoder());
}
}
配置好上述代碼,首先來手動配置兩個角色 budia , admian 以及相應的角色權限和密碼。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 手動配置
auth.inMemoryAuthentication()
.withUser("budai").password("123456").roles("USER")
.and()
.withUser("admin").password("123456").roles("ADMIN", "USER")
.and()
// 自定義賬號信息解析方式
.passwordEncoder(new AESEncoder());
}
自定義加密
Security 中默認提供了強哈希加密方式 BCryptPasswordEncoder,但也可根據實際需求自定義加密邏輯,這通過實現 PasswordEncoder 接口并重寫其方法來完成。在自定義的 PasswordEncoder 實現中,matches 方法的 charSequence 參數實際上是用戶登錄時傳入的密碼(明文),該密碼在驗證前可能已經過解密處理(如果前端使用了AES等加密方式)。而 matches 方法的另一個參數 s(或根據具體實現可能命名為其他變量),則是從數據庫中讀取的、已經加密存儲的用戶密碼值。由于前端工程中實施了AES數據加密,因此在服務器端進行密碼驗證之前,需要先對接收到的加密密碼進行解密操作。
public class AESEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence charSequence) {
String str = charSequence.toString();
try {
String plain;
if (!Objects.equals(str, "userNotFoundPassword")) {
plain = AESUtil.desEncrypt(str);
} else {
plain = str;
}
return AESUtil.encrypt(plain);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public boolean matches(CharSequence charSequence, String s) {
try {
String plain = AESUtil.desEncrypt(charSequence.toString());
String result = AESUtil.encrypt(plain);
return Objects.equals(result, s);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
權限分配
完成用戶角色的創建之后,接下來的步驟是為不同的角色分配相應的資源權限。這通常在SecurityConfig類中通過重寫configure(HttpSecurity http)方法來實現。在該方法中,可以配置哪些接口(如freeAPI、userAPI和adminAPI)可以被特定用戶角色訪問。這些接口的配置信息可以存儲在yml文件中,并通過Spring的注解機制動態獲取。
當未認證用戶嘗試訪問受保護的資源時,Spring Security會自動將請求重定向到登錄頁面,但在這里,我們通過formLogin().loginProcessingUrl("/api/auth/verify")指定了一個自定義的登錄接口地址/api/auth/verify,以支持通過API請求方式進行用戶認證。用戶提交登錄請求后,AuthUserService中的loadUserByUsername()方法將被調用,以驗證用戶的用戶名和密碼,并確定其角色。
對于認證成功、認證失敗以及無權限訪問的情況,我們采用了匿名函數(或Lambda表達式,具體取決于實現方式)來處理這些事件的邏輯。這些處理邏輯可能包括重定向到特定頁面、返回錯誤信息或執行其他自定義操作。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 免認證資源
*/
@Value("${auth.api.free}")
private String freeAPI;
/**
* 普通用戶資源
*/
@Value("${auth.api.user}")
private String userAPI;
/**
* 超級用戶資源
*/
@Value("${auth.api.admin}")
private String adminAPI;
@Override
protected void configure(HttpSecurity http) throws Exception {
String[] freeResource = freeAPI.trim().split(",");
String[] userResource = userAPI.trim().split(",");
String[] adminResource = adminAPI.trim().split(",");
http.authorizeRequests()
// 設置免認證資源
.antMatchers(freeResource).permitAll()
// 為不同權限分配不同資源
.antMatchers(userResource).hasRole("USER")
.antMatchers(adminResource).hasRole("ADMIN")
// 默認無定義資源都需認證
.anyRequest().authenticated()
// 自定義認證訪問資源
.and().formLogin().loginProcessingUrl("/api/auth/verify")
// 認證成功邏輯
.successHandler(this::successHandle)
// 認證失敗邏輯
.failureHandler(this::failureHandle)
// 未認證訪問受限資源邏輯
.and().exceptionHandling().authenticationEntryPoint(this::unAuthHandle)
.and().httpBasic()
// 允許跨域
.and().cors()
// 關閉跨站攻擊
.and().csrf().disable();
}
}
六、邏輯處理
成功處理
用戶成功通過認證后,系統會執行兩個關鍵步驟來管理登錄狀態和權限控制。首先,會生成一個JWT(JSON Web Token)Token值,該Token用于后續請求的登錄狀態管理。JWT是基于登錄用戶的用戶名、密碼(通常是密碼的哈希值,而非明文)及角色信息序列化后的JSON數據計算得出的,確保了數據的安全性和可驗證性。其次,用戶的角色信息會被封裝成一個Authentication認證碼,該認證碼是username:password(注意:這里的password部分應替換為更安全的信息,如用戶ID或角色的哈希值,因為直接包含密碼是不安全的)經過Base64編碼后的值,用于后續的權限過濾。
這兩個認證信息——JWT Token和Authentication認證碼——都會通過HTTP響應的請求頭返回給前端。前端接收到這些信息后,會將其存儲起來,并在后續發出的所有請求中,在請求頭中攜帶這兩個參數。后端則通過配置過濾器與Spring Security框架,實現對這些請求頭的解析,從而驗證用戶的登錄狀態和訪問權限,完成登錄狀態的管理與權限訪問控制。
失敗處理
用戶未通過 Security 認證時,需要通過驗證碼狀態等信息來響應給前端, 在這里我們通過新建的返回類? 來返回結果給前端。
private void failureHandle(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
String msg;
if (exception instanceof LockedException) {
msg = "Account has been locked, please contact the administrator.";
} else if (exception instanceof BadCredentialsException) {
msg = "Account credential error, please recheck.";
} else {
msg = "Account doesn't exist, please recheck.";
}
response.setContentType("application/json;charset=UTF-8");
response.setStatus(203);
ResultData<Object> result = new ResultData<>(203, msg, null);
response.getWriter().write(objectMapper.writeValueAsString(result));
}
無權攔截
在用戶沒有經過 權限認證的情況下訪問資源,則需要進行攔截并返回響應的狀態信息。
private void unAuthHandle(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
String msg = "Please login and try again.";
response.setContentType("application/json;charset=UTF-8");
response.setStatus(203);
ResultData<Object> result = new ResultData<>(203, msg, null);
response.getWriter().write(objectMapper.writeValueAsString(result));
}
七、Filter配置
Bean注入
@Configuration
public class FilterConfig {
/**
* 設置放行資源
*
* 例:/api/auth/verify
*/
@Value("${auth.api.verify}")
private String verifyAPI;
@Bean
public FilterRegistrationBean<AuthFilter> orderFilter1() {
FilterRegistrationBean<AuthFilter> filter = new FilterRegistrationBean<>();
filter.setName("auth-filter");
// Set effect url
filter.setUrlPatterns(Collections.singleton("/**"));
// Set ignore url, when multiply the value spilt with ","
filter.addInitParameter("excludedUris", verifyAPI);
filter.setOrder(-1);
filter.setFilter(new AuthFilter());
return filter;
}
}
攔截邏輯
我們新建一個名為AuthFilter的自定義過濾器類并實現Filter接口時,我們需要重點關注doFilter()方法的實現。如之前所述,一旦用戶通過登錄認證成功,系統會將JWT Token和Authentication認證信息寫入HTTP響應的請求頭中,并返回給前端。之后,前端在發起任何需要認證或權限驗證的請求時,都應在請求頭中包含這兩個參數。
在請求到達后端時,首先會觸發Spring Security的認證流程。Spring Security會使用請求頭中的Authentication認證信息(盡管通常不直接使用username:password格式的Base64編碼,而是可能使用更安全的認證令牌,如預共享密鑰生成的Token或基于HTTP頭部的認證方式)進行初步的身份驗證。這一部分是Spring Security內部自動處理的,我們無需直接操作。
一旦通過Spring Security的身份驗證,請求將繼續流向我們配置的AuthFilter。在AuthFilter的doFilter()方法中,我們需要編寫邏輯來解析請求頭中的JWT Token。這個Token包含了用戶的會話信息,如用戶名、角色以及Token的簽發和過期時間等。我們將驗證這個Token是否有效(比如檢查它是否未過期),如果Token已過期,我們需要構造一個包含相應錯誤信息的響應,并通過HTTP狀態碼(如401 Unauthorized)返回給前端。前端接收到這個響應后,可以根據需要重定向用戶到登錄頁面。
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
int status;
String msg;
String token = req.getHeader("Token");
if (StringUtils.isNotBlank(token)) {
boolean isExpired = false;
try {
TokenUtil.parseJWT(token);
} catch (ExpiredJwtException e) {
isExpired = true;
}
if (!isExpired) {
filterChain.doFilter(req, servletResponse);
return;
} else {
status = 203;
msg = "Login expired.";
}
} else {
status = 203;
msg = "Please login and try again.";
}
response.setContentType("application/json;charset=UTF-8");
response.setStatus(status);
ResultData<Object> result = new ResultData<>(status, msg, null);
response.getWriter().write(objectMapper.writeValueAsString(result));
}
八、跨域處理
在工程中新建 CorsConfig 類實現 WebMvcConfigurer 接口并重寫 addCorsMappings() 方法配置跨域信息
@Configuration
public class CorsConfig implements WebMvcConfigurer {
/**
* 設置跨域訪問地址,逗號分隔
*
* 例:http://localhost:8080,http://127.0.0.1:8080
*/
@Value("${auth.host.cors}")
private String hosts;
@Override
public void addCorsMappings(CorsRegistry registry) {
String[] crosHost = hosts.trim().split(",");
// 設置允許跨域的路徑
registry.addMapping("/**")
// 設置允許跨域請求的域名
.allowedOriginPatterns(crosHost)
// 是否允許cookie
.allowCredentials(true)
// 設置允許的請求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 設置允許的header屬性
.allowedHeaders("*")
// 跨域允許時間
.maxAge(TimeUnit.SECONDS.toMillis(5));
}
}

浙公網安備 33010602011771號