文件
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
具體程式碼參考 這裡