1. 程式人生 > 其它 >spring boot專案14:安全-基礎使用-MySQL(1)

spring boot專案14:安全-基礎使用-MySQL(1)

JAVA 8

Spring Boot 2.5.3

MySQL 5.7.21(單機)

---

授人以漁:

1、Spring Boot Reference Documentation

This document is also available as Multi-page HTML, Single page HTML and PDF.

有PDF版本哦,下載下來!

2、Spring Security Reference

PDF版本哦(網頁版末尾的 /html5/ 改為 /pdf/),下載下來!

目錄

1、安全初體驗

2、自定義表單登入頁

3、多使用者、角色、認證

使用InMemoryUserDetailsManager

使用JdbcUserDetailsManager

4、自定義資料庫模型

使用者過期試驗

參考文件

本文使用專案:

mysql-hello

Web專案,底層使用MySQL儲存資料,預設埠30000。

MySQL配置——後面會用到:

資料庫配置
#
# MySQL on Ubuntu
spring.datasource.url=jdbc:mysql://mylinux:3306/db_example?serverTimezone=Asia/Shanghai
spring.datasource.username=springuser
spring.datasource.password=ThePassword
#spring.datasource.driver-class-name =com.mysql.jdbc.Driver # This is deprecated
spring.datasource.driver-class-name =com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
# 開啟使用過程中執行的SQL語句
spring.jpa.show-sql: true

1、安全初體驗

新增依賴包 spring-boot-starter-security:

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

包結構:

啟動專案,此時,任何連結都不能訪問。

啟動日誌:

Using generated security password 後面是 預設使用者user的密碼。

在瀏覽器中訪問,彈出登入對話方塊:

登入頁-原始碼
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <title>Please sign in</title>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
  </head>
  <body>
     <div class="container">
      <form class="form-signin" method="post" action="/login">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>
          <label for="username" class="sr-only">Username</label>
          <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
        </p>
        <p>
          <label for="password" class="sr-only">Password</label>
          <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        </p>
<input name="_csrf" type="hidden" value="ed3f49ac-647f-4a59-b2e3-b24498725774" />
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
      </form>
</div>
</body></html>

原始碼裡面有一個提交資料 /login 的表單——實現登入。

輸入 user、日誌中的密碼,登入成功。

除了上面的 /login 實現登入,還有一個 /logout 端點實現 退出登入:

隨機密碼,而且存在日誌裡面,不好。配置下面的可以實現固定使用者及密碼:

# 安全
spring.security.user.name=lib
spring.security.user.password=123

再次啟動,日誌沒有密碼資訊了。

瀏覽器登入,使用上面配置的 lib、123即可。

小結,

上面的專案很簡單,但有一定實用性了

2、自定義表單登入頁

登入頁:login.html

static/login.html
<html>
<head>
	<title>login:mysql-hello</title>
	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
	<style>
		body {
			background: #ddd;
		}
	</style>
</head>
<body>
<div>請登入:</div>
<form action="login.html" method="post">
	<div>使用者名稱:<input type="text" name="username" placeholder="使用者名稱" /></div>
	<div>密碼:<input type="password" name="password" placeholder="密碼" /></div>
	<div><a href="#">忘記密碼?</a></div>
	<div><input type="submit" value="登入" /> </div>
</form>
<br />
<br />
<div><a href="#">新使用者註冊</a></div>
</body>
</html>

注,包含username, password的<input>,注意<form>的action和method來自部落格園

新增 AppWebSecurityConfig.java,繼承 WebSecurityConfigurerAdapter 並重寫 configure(HttpSecurity http)

@EnableWebSecurity
public class AppWebSecurityConfig extends WebSecurityConfigurerAdapter {

	/**
	 * 自定義登入頁:login.html
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
				.anyRequest().authenticated()
				.and()
			.formLogin()
				// 自定義登入頁
				.loginPage("/login.html")
				.permitAll()
				.and()
			.csrf().disable();
	}
}

登入頁面:

輸入前面配置檔案中的使用者名稱、密碼,登入成功(首頁沒有建,顯示status=404),但可以測試其它連結的。

指定處理登入的URL-未通過

在formLogin()下,指定處理登入的URL:

.formLogin()
	// 自定義登入頁
	.loginPage("/login.html")
	// 處理登入請求的URL
	.loginProcessingUrl("/login")

但是,測試失敗,登入未成功。

錯誤資訊
瀏覽器頁面:
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Sat Sep 04 23:03:14 CST 2021
There was an unexpected error (type=Method Not Allowed, status=405).
---

應用日誌:
Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported]
Completed 405 METHOD_NOT_ALLOWED
"ERROR" dispatch for POST "/error", parameters={masked}

疑問

為什麼呢?預設登入頁的 action不就是 “/login” 嗎?怎麼這裡配置了就不行呢?

像上面配置後,預設的/login 無效了?需要自己寫?怎麼寫?格式呢?TODO

登入返回值

上面的試驗中,登入成功後,跳轉到首頁。在真實的前後端分離系統中,登入後一般返回 成功與否的資訊,比如,一段JSON資料,再由前端決定怎麼處理——跳轉到哪裡。

在formLogin()下,配置 successHandler、failureHandler 分別實現登入成功、失敗後的邏輯。來自部落格園

.formLogin()
	// 自定義登入頁
	.loginPage("/login.html")
	// 處理登入請求的URL
	// 指定後登入失敗,註釋掉,TODO
//				.loginProcessingUrl("/login")
	// 登入成功的處理
	.successHandler(new AuthenticationSuccessHandler() {
		
		@Override
		public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp,
				Authentication auth) throws IOException, ServletException {
			resp.setContentType("application/json;charset=utf-8");
			PrintWriter out = resp.getWriter();
			out.write(ResultVO.getSuccess("登入成功").toString());
		}
	})
	// 登入失敗的處理
	.failureHandler(new AuthenticationFailureHandler() {
		
		@Override
		public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp,
				AuthenticationException ex) throws IOException, ServletException {
			resp.setContentType("application/json;charset=utf-8");
			resp.setStatus(HttpStatus.UNAUTHORIZED.value());
			PrintWriter out = resp.getWriter();
			out.write(ResultVO.getFailed(HttpStatus.UNAUTHORIZED.value(), "登入失敗", "請重新登入").toString());
		}
	})
	.permitAll()
	.and()

注,ResultVO 是專案的一個 統一返回物件類,getSuccess、getFailed是其中的靜態方法

測試結果:成功

3、多使用者、角色、認證

前面的章節,只有一個使用者。本章介紹多個使用者的使用。

自定義一個 UserDetailsService Bean即可。

介面有很多實現類,其中:來自部落格園

1)InMemoryUserDetailsManager 的使用者資料 儲存到 記憶體,重啟後丟失

2)JdbcUserDetailsManager 的使用者資料 儲存到 資料庫,比如,MySQL資料庫

使用InMemoryUserDetailsManager

準備3個介面:

/security/admin/hello 需要ADMIN角色的使用者才可以訪問

/security/user/hello 需要USER角色的使用者才可以訪問

/security/app/hello 任意登入使用者都可以訪問

SecurityAdminController.java
@RestController
@RequestMapping(value="/security/admin")
@Slf4j
public class SecurityAdminController {

	@GetMapping(value="/hello")
	public String hello() {
		return "hello, Admin";
	}
	
}

其它兩個Controller類似。來自部落格園

更改 AppWebSecurityConfig:

之前的configure函式做了改動;

增加了 UserDetailsService Bean的生成函式,並增加了2個使用者對應不同的角色;

passwordEncoder函式 在 本文使用的 S.B.版本是必須的,否則發生異常,,但這個NoOpPasswordEncoder過期了,,原因及解決方案有待進一步研究,TODO

	/**
	 * 試驗2:資源授權
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
				// 使用角色
				.antMatchers("/security/admin/**").hasRole("ADMIN")
				.antMatchers("/security/user/**").hasRole("USER")
				.antMatchers("/security/app/**").permitAll()
				.anyRequest().authenticated()
				.and()
			.formLogin().permitAll()
				.and()
			.csrf().disable();
	}
	
	/**
	 * 基於記憶體資料庫的使用者資訊
	 */
	@Bean
	public UserDetailsService userDetailsService() {
		// 基於記憶體的使用者資訊:2個使用者,不同角色
		InMemoryUserDetailsManager man = new InMemoryUserDetailsManager();
		man.createUser(User.withUsername("user").password("123").roles("USER").build());
		man.createUser(User.withUsername("admin").password("123").roles("ADMIN").build());
		return man;
	}
	
	/**
	 * 必須有,否則發生異常
	 * 是否可以使用其它 PasswordEncoder 的實現類呢?
	 * 據說是 5.X版本之後預設啟用了 委派密碼編碼器 導致
	 * @author ben
	 * @date 2021-09-05 00:10:49 CST
	 * @return
	 */
	@Bean
	public PasswordEncoder passwordEncoder() {
		// 過時了?怎麼弄?TODO
		// 因為不安全,只能用於測試、明文密碼驗證等,故廢棄
		return NoOpPasswordEncoder.getInstance();
	}

注意,上面的配置後,配置檔案中的 lib 使用者就不能使用了

啟動應用,測試:

user、admin分別訪問前面的 3個介面。

使用者/介面 user admin
/security/admin/hello type=Forbidden, status=403 hello, Admin
/security/user/hello hello, User type=Forbidden, status=403
/security/app/hello hello, APP hello, APP

符合預期。來自部落格園

更進一步:

動態管理使用者(增刪改查),或可以使用 容器中的 userDetailsService Bean——即上面配置生成了。

轉換為 InMemoryUserDetailsManager 後進行操作。

不過,應用重啟後,這些使用者資料丟失,意義不大,但從介面來看是可以做到的。

使用JdbcUserDetailsManager

引入:來自部落格園

<!-- 使用JdbcUserDetailsManager時引入,沒有JPA的嗎? -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
	<groupId>mysql</groupId>
	<artifactId>mysql-connector-java</artifactId>
	<scope>runtime</scope>
</dependency>

注,本專案中,mysql-connector-java早已引入

在MySQL建立資料表:找到 JdbcUserDetailsManager 類 對應的jar包(spring-security-core),DDL檔案位於 同一個jar包的 org.springframework.security.core.userdetails.jdbc.users.ddl

拷貝其中的語句,改其中的 varchar_ignorecase 為 varchar型別——MySQL支援。來自部落格園

使用改造後的語句到MySQL終端去執行:下圖展示執行成功,建立了兩張表 users、authorities

改造 AppWebSecurityConfig 的userDetailsService函式:

	/**
	 * 使用JdbcUserDetailsManager
	 * 本應用的底層為 MySQL資料庫——上面的dataSource
	 */
	@Bean
	public UserDetailsService userDetailsService() {
		JdbcUserDetailsManager man = new JdbcUserDetailsManager();
		System.out.println("dataSource=" + dataSource);
		man.setDataSource(dataSource);
		man.createUser(User.withUsername("user").password("123").roles("USER").build());
		man.createUser(User.withUsername("admin").password("123").roles("ADMIN").build());
		return man;
	}

測試 兩個使用者對前面3個介面的許可權:測試成功,符合預期

注,上面的 dataSource是 HikariPool-1:

JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. 
Therefore, database queries may be performed during view rendering. Explicitly configure 
spring.jpa.open-in-view to disable this warning
dataSource=HikariDataSource (HikariPool-1)

啟動後,新建資料表的資料:

注意,角色建立時是 user、admin,但在 資料庫裡面是 以“ROLE_”開頭

再次啟動應用,發生異常,啟動失敗,因為 user、admin在資料庫中已經存在了。來自部落格園

改造userDetailsService()函式:多了使用者存在性判斷

	@Bean
	public UserDetailsService userDetailsService() {
		JdbcUserDetailsManager man = new JdbcUserDetailsManager();
		man.setDataSource(dataSource);
		
		if (!man.userExists("user")) {
			man.createUser(User.withUsername("user").password("123").roles("USER").build());
		}
		if (!man.userExists("admin")) {
			man.createUser(User.withUsername("admin").password("123").roles("ADMIN").build());
		}
		return man;
	}

預設的資料庫模型肯定無法滿足生產的需求,比如,裡面的密碼都沒有加密。

Spring Security具有優良的擴充套件性,可以很好地實現自定義的資料庫模型。

---210905 01:55---寫到這兒了---

4、自定義資料庫模型

在使用JdbcUserDetailsManager的預設資料庫模型時,使用者、許可權是分成兩張表的。來自部落格園

本章介紹 基於自定義資料庫模型的認證和授權。

兩個步驟:1)實現UserDetails——使用者詳情;2)實現UserDetailsService——使用者詳情服務(類似於前面的2個Manager);

cofigure函式保持不變。

AppUser類,使用者實體類,也實現了 UserDetails 介面。

AppUser.java
package org.lib.mysqlhello.security.self;

import java.util.Collection;
import java.util.Date;
import java.util.List;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Transient;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;

/**
 * 自定義使用者
 * @author ben
 * @date 2021-09-05 09:26:11 CST
 */
@Entity
@Data
@Slf4j
public class AppUser implements UserDetails {

	private static final long serialVersionUID = 210905L;

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(columnDefinition = "VARCHAR(50) NOT NULL UNIQUE")
	private String username;
	
	@Column(columnDefinition = "VARCHAR(384) NOT NULL")
	private String password;

	/**
	 * 使用者角色
	 * 多個角色使用英文都好(,)隔開
	 */
	@Column(columnDefinition = "VARCHAR(500) NOT NULL")
	private String roles;
	
	/**
	 * 使用者是否啟用:預設啟用
	 */
	@Column(columnDefinition = "BIT(1) DEFAULT true")
	private Boolean enabled;

	/**
	 * 有效期時間戳
	 * 預設為0 永久有效
	 */
	@Column(columnDefinition = "BIGINT DEFAULT 0")
	private Long expiration;
	
	/**
	 * 建立時間
	 */
	@Column(insertable = false, columnDefinition = "DATETIME DEFAULT NOW()")
	private Date createTime;
	
	/**
	 * 更新時間
	 */
	@Column(insertable = false, updatable = false, columnDefinition = "DATETIME DEFAULT NOW() ON UPDATE NOW()")
	private Date updateTime;

	// ----實現UserDetails介面----
	
	// set函式已使用 @Data 註解建立
	@Transient
	private List<GrantedAuthority> authorities;
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return authorities;
	}

	@Override
	public boolean isAccountNonExpired() {
		if (this.expiration <= 0) {
			return true;
		}
		
		if (this.expiration >= System.currentTimeMillis()) {
			return true;
		}
		
		log.warn("使用者過期:id={}, expiration={}", this.id, this.expiration);
		return false;
	}

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

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

	@Override
	public boolean isEnabled() {
		return this.enabled;
	}
	
	// ----實現UserDetails介面----
	
}

啟動應用,資料表建好了:

插入兩條資料(使用者):

-- 和之前不同,admin有兩個角色哦
insert into app_user(username, password, roles) values("admin", "123", "ROLE_ADMIN,ROLE_USER");
insert into app_user(username, password, roles) values("user", "123", "ROLE_USER");

AppUserDetailsService類:實現了 UserDetailsService介面,並使用 @Service註解。來自部落格園

@Service
public class AppUserDetailsService implements UserDetailsService {

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		return null;
	}

}

上面的 AppUserDetailsService Bean 還無法使用:

前一章 的 userDetailsService() 函式也生成了 userDetailsService Bean,此時,雖然應用可以啟動,但是,無法登入——因為有兩個 userDetailsService Beans吧。

註釋掉AppWebSecurityConfig類 的 userDetailsService() 函式。來自部落格園

啟動應用,登入:AppUserDetailsService 還沒寫完導致

繼續改造 AppUserDetailsService...

改造後的 AppUserDetailsService:來自部落格園

package org.lib.mysqlhello.security.self;

import java.util.Objects;
import java.util.function.Consumer;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * AppUserDetailsService
 * @author ben
 * @date 2021-09-05 10:34:23 CST
 */
@Service
public class AppUserDetailsService implements UserDetailsService {

	@Autowired
	private AppUserDAO appUserDao;
	
	private Consumer<Object> cs = System.out::println;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		AppUser user = appUserDao.findByUsername(username);
		cs.accept("user 1=" + user);
		if (Objects.isNull(user)) {
			throw new UsernameNotFoundException("使用者不存在");
		}
		
		// 許可權集
		// 使用Spring Security的AuthorityUtils:預設支援 英文逗號分開的許可權集
		user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
		
		cs.accept("user 1=" + user);
		return user;
	}

}

啟動應用,測試已新增的使用者admin、user訪問各個介面:成功,符合預期來自部落格園

UsernameNotFoundException說明

繼承了 AuthenticationException——其下有若干的異常。

使用者過期試驗

在AppUserDetailsService#loadUserByUsername函式中丟擲使用者過期異常

失敗了

看來不是這麼用的。來自部落格園

記得 AppUser 實現 UserDetails介面 時,有一個 isAccountNonExpired() 函式,或許,過期的判斷已經實現了。

設定user過期時間——30秒有效期:

-- 當前時間+30秒過期
-- 注意使用 (unix_timestamp(now())+30)*1000!
-- 最開始只使用 now() 時驗證失敗/sad
mysql> update  app_user set expiration=(unix_timestamp(now())+30)*1000 where id = 2;

在執行上面的語句後,啟動應用,使用 user登入:登入成功。

30秒後繼續操作,可以繼續操作,沒有被阻止。TODO

30秒後,在另一個瀏覽器重新登入:登入失敗,提示賬號過期。

回到之前已登入的瀏覽器操作:可以繼續,但會輸出 isAccountNonExpired() 函式的 過期日誌:來自部落格園

可是,怎麼阻止過期使用者繼續操作啊?!

》》》全文完《《《來自部落格園

補充:

public interface UserDetails extends Serializable

其下的User類

public class User implements UserDetails, CredentialsContainer {

public interface UserDetailsService

後記:

密碼沒有加密啊?

阻止過期使用者繼續訪問啊?來自部落格園

記住使用者?記住使用者多長時間?

登入過程中都做了什麼?過濾器、攔截器啥的?

自動登入呢?

基於token的登入呢?

……

看來,還要搞更多試驗、更多學習才是啊!

後面再寫一篇好了。來自部落格園

參考文件

1、《Spring Security實戰》

書,作者:陳木鑫,2019年8月第1版

非常感謝。

2、