文件

Spring Security Reference

SpringBoot+SpringSecurity+jwt整合及初體驗

JSON Web Token 入門教程 - 阮一峰

JWT 官網

SpringSecurity

專案 GitHub 倉庫地址:https://github.com/aaronlinv/springsecurity-jwt-demo

依賴

主要用到了: SpringSecurity,Thymeleaf,Web,Lombok

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependency>

頁面

編寫頁面和 Controller 進行測試,具體頁面可以看 程式碼

主要包含了首頁(index),訂單(order),還有 user,role,menu這三個位於 /system 下,需要 admin 許可權

使用記憶體使用者進行表單登入

static 下新建 login.html,用於登入

<form action="/login" method="post">
<label for="username">賬戶</label><input type="text" name="username" id="username"><br>
<label for="password">密碼</label><input type="password" name="password" id="password"><br>
<input type="submit" value="登入">
</form>

編寫繼承 WebSecurityConfigurerAdapter 的 Security 配置類,並開啟 @EnableWebSecurity 註解,這個註解包含了 @Configuration

WebSecurityConfigurerAdapter 中有兩個方法,它們名稱相同,但是入參不同

protected void configure(HttpSecurity http) throws Exception
protected void configure(AuthenticationManagerBuilder auth) throws Exception

入參為 HttpSecurity 的 configure 可以配置攔截相關的引數

另一個入參為 AuthenticationManagerBuilder,則是用來配置驗證相關的引數

@EnableWebSecurity
// @Configuration 被包括在上面的註解了
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
// 配置 PasswordEncoder 用於密碼的加密和匹配
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
} @Override
protected void configure(HttpSecurity http) throws Exception {
//
http
// 配置表單登入相關引數
.formLogin()
// 登入頁面
.loginPage("/login.html")
// 表單提交的地址
.loginProcessingUrl("/login")
// 登入成功後跳轉的地址
.defaultSuccessUrl("/index") // .and() 方法返回的是 HttpSecurity 物件
.and()
// 配置許可權相關引數
.authorizeRequests()
// 匹配路徑
// 需要開放登入的地址,否則訪問登入頁面時因為沒有許可權,自動跳轉到登入頁,進入死迴圈,導致報錯:重定向的次數過多
.antMatchers("/login.html", "/login")
// 允許訪問
.permitAll() // 匹配路徑
.antMatchers("/order")
// 必須有指定的任意許可權才能訪問
.hasAnyAuthority("ROLE_user", "ROLE_admin") // 匹配 /system 下的所有路徑
.antMatchers("/system/**")
// 擁有指定角色才能訪問
.hasRole("admin") // 除了上面的路徑,其他都需要認證
.anyRequest().authenticated() // 返回 HttpSecurity 物件
.and()
// 關閉 csrf (跨站請求偽造)
.csrf().disable(); // 設定 登出地址
http.logout().logoutUrl("/logout");
} @Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 配置驗證
// 使用記憶體(非持久化)驗證
auth.inMemoryAuthentication()
// 配置使用者名稱
.withUser("user")
// 配置用 PasswordEncoder 加密後的密碼
.password(passwordEncoder().encode("1234"))
// 配置角色
.roles("user")
.and() .withUser("admin")
.password(passwordEncoder().encode("1234"))
.roles("admin") .and()
// 配置授權時預設使用的 PasswordEncoder
.passwordEncoder(passwordEncoder());
;
}
}

具體程式碼參考 這裡

兩個 configure 非常類似,入參物件的方法中包含了具體的配置項,如:formLogin,authorizeRequests,csrf,logout 等等,部分配置項還可以通過鏈式呼叫,進行該配置項更詳細地配置,通過 .and() 可以回到 HttpSecurity 物件,再定義其他配置項

使用表單的方式登入需要配置:表單 (formLogin)、授權(authorizeRequests) 、跨站請求偽造(csrf)、登出(logout),還需要配置驗證,先使用最簡單的 inMemoryAuthentication,並指定賬戶密碼,再指定密碼編碼器

然後啟動服務,訪問登入頁面(注意這裡的被修改為 8081),輸入不同的賬號密碼,測試不同頁面的訪問情況,沒有許可權會提示:403

http://localhost:8081/login.html

使用 Json 傳遞引數,自定義 Handler

修改登入頁面,使用 Ajax 向後端傳遞 賬戶和密碼,需要使用 POST

<head>
<meta charset="UTF-8">
<title>登入</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body> <form action="/login" method="post">
<label for="username">賬戶</label><input type="text" name="username" id="username"><br>
<label for="password">密碼</label><input type="password" name="password" id="password"><br>
<input type="submit" onclick="login()" value="登入">
</form>
</body>
<script>
function login() {
$.ajax({
type: "POST",
url: "/login",
data: {
"username": $("#username").val(),
"password": $("#password").val(),
},
success: function (data) {
if (data.code == 20001) {
Location.href = "/index";
} else {
alert(data.msg);
}
}
})
}
</script>

需要編寫登入成功和登入失敗時呼叫的 Handler,並配置到SecurityConfig 中

@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json; charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write("{\"code\":\"40001\",\"msg\":\"登入失敗\"}");
writer.flush();
writer.close();
}
}
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json; charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write("{\"code\":\"20001\",\"msg\":\"登入成功\"}");
writer.flush();
writer.close();
}
}

在 SecurityConfig 中 注入並配置 Handler

    @Autowired
private AuthenticationSuccessHandler successHandler; @Autowired
private AuthenticationFailureHandler failureHandler; @Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
// 指定 Handler
.successHandler(successHandler)
.failureHandler(failureHandler)
// 省略其他程式碼...
}

具體程式碼參考 這裡

登入頁面進行測試:http://localhost:8081/login.html

首頁:http://localhost:8081/

基於資料庫的認證

建立資料庫 jwt_demo ,匯入表資料:sql 指令碼

users 表,包括欄位:user_id,user_name,password,status,roles

匯入 MySQL 驅動和 JPA 的依賴

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

在 application.properties 中配置資料庫資訊

server.port=8081
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.url=jdbc:mysql://localhost:3306/jwt_demo?serverTimezone=GMT%2B8&characterEncoding=utf-8

UserDetails 介面是 SpringSecurity 用來承載使用者資訊的載體,SpringSecurity 提供了對這個介面的實現類:org.springframework.security.core.userdetails.User,我們自己定義的使用者類通常也叫User,所以導包時候要注意使用 我們自己定義的 User 類

@Entity
@Table(name = "users")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long userId; @Column(name = "user_name")
private String userName; @Column(name = "password")
private String password; @Column(name = "status")
private String status; @Column(name = "roles")
private String roles; // 物件的許可權列表,不需要持久化
@Transient
private List<GrantedAuthority> authorities; public void setAuthorities(List<GrantedAuthority> authorities) {
this.authorities = authorities;
} // 必須重寫介面的對於 getPassword,getUsername,getAuthorities 等方法
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
} @Override
public String getUsername() {
return this.userName;
} // 下面 4 個需要方法 return 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;
}
}

定義 JPA 的 Repository

@Repository
public interface UserDao extends JpaRepository<User, Long> {
}

定義 Service

public interface UserService {
public User selectUserByUserName(String username);
}

定義 Service 對應的實現,通過查詢使用者名稱獲得使用者相關資訊

@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserDao userDao; @Override
public User selectUserByUserName(String username) {
User user = new User();
user.setUserName(username);
List<User> list = userDao.findAll(Example.of(user));
return list.isEmpty() ? null : list.get(0);
}
}

還需要編寫 UserDetailService,供 SpringSecurity 的 DaoAuthenticationProvider 類中的 retrieveUser 方法呼叫,以此獲得對應使用者的資訊

@Service
public class UserDetailService implements UserDetailsService {
@Autowired
private UserService userService; @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 呼叫 Service
User user = userService.selectUserByUserName(username);
if (user == null) {
throw new UsernameNotFoundException("使用者" + user.getUsername() + "不存在");
}
// 設定許可權
// commaSeparatedStringToAuthorityList 方式將字串間通過 ',' 進行分割,然後返回 List
user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
return user;
}
}
// 省略其他...
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailService userDetailService; @Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 將記憶體授權方式替換為自己實現的 UserDetailService
auth.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder());
// 省略其他...
}

具體程式碼參考 這裡

登入頁面進行測試:http://localhost:8081/login.html

首頁:http://localhost:8081/

整合 JWT

新增 jjwt 依賴

        <dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>

在 application.properties 中配置 JWT 引數

token.header:Authorization
# 令牌祕鑰
token.secret:askdhfkjahskjdfhkalsjhdf^112asdfasdf44^%$_@+asdfasdfaskjdhfkjashdfljkahsdklsfjasgdkjfgjahs(IS:)_@@+asdfasdfaskjdhfkjashdfljkahsdklsfja@+asdfasdfaskjdhfkjashdfljkahsdklsfjasgdkjfgjahssgdkjfgjahsdgfjhgsdfsadf+-asdfasdas+as++_sdfsdsasdfasdf
# 令牌有效期(預設30分鐘)
token.expireTime:3600000

定義統一 API 封裝格式

public class RestResult extends HashMap<String, Object> {
private static final long serialVersionUID = 1L;
// 狀態碼
public static final String CODE_TAG = "code";
// 返回內容
public static final String MSG_TAG = "msg";
// 資料物件
public static final String DATA_TAG = "data"; public RestResult() { } public RestResult(int code, String msg) {
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
} public RestResult(int code, String msg, Object data) {
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
if (data != null) {
super.put(DATA_TAG, data);
}
} public static RestResult success() {
return new RestResult(200, "成功");
}
}

然後準備 JWT 工具類,實現:生成 token、從 token 中獲取使用者名稱、檢查 token 是否過期、重新整理 token、驗證 token 等,這裡的 KEY 通過雙重鎖 保證了執行緒安全

@Data
@Component
@Slf4j
public class JwtTokenUtils {
@Value("${token.secret}")
private String secret; @Value("${token.expireTime}")
private Long expiration; @Value("${token.header}")
private String header; private static Key KEY = null; /**
* 生成token令牌
*
* @param userDetails 使用者
* @return 令token牌
*/
public String generateToken(UserDetails userDetails) {
log.info("[JwtTokenUtils] generateToken " + userDetails.toString());
Map<String, Object> claims = new HashMap<>(2);
claims.put("sub", userDetails.getUsername());
claims.put("created", new Date()); return generateToken(claims);
} /**
* 從令牌中獲取使用者名稱
*
* @param token 令牌
* @return 使用者名稱
*/
public String getUsernameFromToken(String token) {
String username = null;
try {
Claims claims = getClaimsFromToken(token);
username = claims.get("sub", String.class);
log.info("從令牌中獲取使用者名稱:" + username);
} catch (Exception e) {
username = null;
}
return username;
} /**
* 判斷令牌是否過期
*
* @param token 令牌
* @return 是否過期
*/
public Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return false;
}
} /**
* 重新整理令牌
*
* @param token 原令牌
* @return 新令牌
*/
public String refreshToken(String token) {
String refreshedToken;
try {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date()); refreshedToken = generateToken(claims);
} catch (Exception e) {
refreshedToken = null;
}
return refreshedToken;
} /**
* 驗證令牌
*
* @param token 令牌
* @param userDetails 使用者
* @return 是否有效
*/
public Boolean validateToken(String token, UserDetails userDetails) { String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) &&
!isTokenExpired(token));
} /**
* 從claims生成令牌
*
* @param claims 資料宣告
* @return 令牌
*/
private String generateToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis() + expiration);
return Jwts.builder().setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS256, getKeyInstance())
.compact();
} /**
* 從令牌中獲取資料宣告
*
* @param token 令牌
* @return 資料宣告
*/
private Claims getClaimsFromToken(String token) {
Claims claims = null; try {
claims = Jwts.parser().setSigningKey(getKeyInstance()).parseClaimsJws(token).getBody();
} catch (Exception e) {
claims = null;
}
return claims;
} private Key getKeyInstance() {
if (KEY == null) {
synchronized (JwtTokenUtils.class) {
if (KEY == null) {// 雙重鎖
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret);
KEY = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
}
}
}
return KEY;
}
}

然後定義 JwtAuthTokenFilter,用於過濾請求

@Component
public class JwtAuthTokenFilter extends OncePerRequestFilter {
@Autowired
private UserDetailsService userDetailsService; @Autowired
private JwtTokenUtils jwtTokenUtils; @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 從請求頭中獲取 Authorization 的值,即 token
String jwtToken = request.getHeader(jwtTokenUtils.getHeader()); if (!ObjectUtils.isEmpty(jwtToken)) {
// 從 token 中獲取使用者名稱,使用者名稱儲存在負載中,負載一般沒有加密,所以負載的內容是可以見,不能在其中存放敏感資訊
// 可以通過 https://jwt.io/ 進行解碼
String username = jwtTokenUtils.getUsernameFromToken(jwtToken); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 通過 userDetailsService 從資料庫中獲取對應使用者的資訊
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 這裡校驗 token 有效性
if (jwtTokenUtils.validateToken(jwtToken, userDetails)) {
// 將 UserDetails 物件 封裝為 UsernamePasswordAuthenticationToken 物件
// 第一引數是 Object principal,傳入的是 UserDetails 物件,在後面的 Service 中會取出 principal
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// 交給SpringSecurity管理,在之後的過濾器不會被攔截進行二次授權了
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
// 將請求轉發給過濾器鏈上的下一個物件
chain.doFilter(request, response);
}
}

編寫 JwtAuthService,處理登入的相關邏輯,使用 AuthenticationManager 對傳入的賬號密碼進行認證,成功返回 生成的 token

@Service
public class JwtAuthService {
@Autowired
private JwtTokenUtils jwtTokenUtils; @Autowired
private AuthenticationManager authenticationManager; public String login(String username, String password) {
Authentication authentication = null;
try {
authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
} catch (Exception e) {
throw new RuntimeException("使用者名稱或密碼有誤");
}
// 這裡就是獲取的就是在前面 JwtAuthTokenFilter 中傳入的 principal
User loginUser = (User) authentication.getPrincipal();
return jwtTokenUtils.generateToken(loginUser);
}
}

用於登入的 Controller

@RestController
public class JwtLoginController {
@Autowired
private JwtAuthService jwtAuthService; @PostMapping({"/login", "/"})
public RestResult login(String username, String password) {
RestResult result = RestResult.success();
String token = jwtAuthService.login(username, password);
result.put("token", token);
return result;
}
}

在 SecurityConfig 中 注入並配置 Handler

    // 省略其他程式碼...
@Autowired
private JwtAuthTokenFilter jwtAuthTokenFilter; // 重寫 AuthenticationManager,避免報錯
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
} @Override
protected void configure(HttpSecurity http) throws Exception {
// http.formLogin()
// .loginPage("/login.html")
// .loginProcessingUrl("/login")
// // .defaultSuccessUrl("/index")
// // .defaultSuccessUrl("/index")
// .successHandler(successHandler)
// .failureHandler(failureHandler)
http.sessionManagement()
// 不建立和使用 session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and() .authorizeRequests()
.antMatchers("/login")
.anonymous() .antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js")
.permitAll()
// 省略其他程式碼... // 使用 JWT 過濾器
http.addFilterBefore(jwtAuthTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
// 省略其他程式碼...

可以通過 Postman 先指定引數(注意是用 POST),獲取 token:

http://localhost:8081/login?username=user&password=1234

在 Headers 中新增 Authorization,值為獲取到的 token

使用 GET 訪問:http://localhost:8081/order

因為 user 沒有管理許可權,所以訪問管理頁面會 403:http://localhost:8081/system/role

具體程式碼參考 這裡