上一章我們?cè)敿?xì)介紹了springsecurity的基礎(chǔ)結(jié)構(gòu),并且在第三章我們已經(jīng)知道了springsecurity中的
基礎(chǔ)配置,但是那是基于all in one模式的,前端頁(yè)面和后端集成在一起,然而事實(shí)上現(xiàn)在越來(lái)越多的開(kāi)發(fā)模式是前后端分離,后端提供的restful接口,那我們本章就來(lái)學(xué)習(xí)下
springsecurity+jwt整合restful服務(wù)。
springboot整合springsecurity系列文章:
springboot整合springsecurity從Hello World到源碼解析(一):hello world程序入門
springboot整合springsecurity從Hello World到源碼解析(二):springsecurity配置加載解析
springboot整合springsecurity從Hello World到源碼解析(三):基礎(chǔ)配置詳解
springboot整合springsecurity從Hello World到源碼解析(四):springsecurity基礎(chǔ)架構(gòu)解析
springboot整合springsecurity從Hello World到源碼解析(五):springsecurity+jwt整合restful服務(wù)
jwt
首先我們先了解下什么是jwt,jwt全稱是json web tokens,它是基于RFC 7519開(kāi)放標(biāo)準(zhǔn)用于雙方安全展示信息的一種方式。通俗說(shuō)就是是用于服務(wù)端和客戶端相互交換信息的一種憑證。
這個(gè)憑證我們就叫token,在傳統(tǒng)的認(rèn)證模式中,我們普遍的做法是這樣的:
用戶登錄-》服務(wù)端生成session-》寫入sessionId到cookie-》瀏覽器攜帶該session訪問(wèn)服務(wù)端-》服務(wù)器基于該sessionId查找信息-》認(rèn)證通過(guò)(找到了)
這種做法存在如下問(wèn)題:
- 服務(wù)端需一定資源保存session信息,用戶多時(shí)資源消耗較大
- 擴(kuò)展性不好,當(dāng)我們的服務(wù)端需要集群時(shí),因session保存在服務(wù)端,此時(shí)無(wú)法定位session,造成登錄失效(傳統(tǒng)解決辦法:iphash,session寫入redis等)
- 跨域問(wèn)題,當(dāng)我們?cè)L問(wèn)A網(wǎng)站時(shí),此時(shí)不想再登錄就能夠訪問(wèn)關(guān)聯(lián)網(wǎng)站B。(傳統(tǒng)解決辦法:寫入持久層,A,B同時(shí)訪問(wèn))
雖然上面面對(duì)的問(wèn)題我們普遍都有解決辦法,但是顯然都不怎么 ”友好“,所以這個(gè)時(shí)候就有一種干脆的解決辦法了,服務(wù)端不再保存session,這樣就輕松解決了上面所有問(wèn)題。
服務(wù)端只頒發(fā)token,那么現(xiàn)在的流程變成了這樣:
用戶登錄-》服務(wù)端頒發(fā)token-》客戶端保存token(放入cooken或者h(yuǎn)eader)-》攜帶token訪問(wèn)服務(wù)端-》服務(wù)端驗(yàn)證token(通過(guò))-》調(diào)用api-》獲取信息
那么既然是服務(wù)端頒發(fā)的token,那肯定要保證該token的安全(只有該服務(wù)端頒發(fā)的token才認(rèn)),唯一(不能偽造),而jwt則是用來(lái)生成這個(gè)安全的token的。 jwt的組成如下:
- header(頭),保存算法,類型
- payload(負(fù)載),用戶的信息,如id,用戶名等等
-
signature(簽名),將生成的token編碼(加密)
他們之間用 "."號(hào)隔開(kāi),例如:xxxxx.yyyyy.zzzzz
token
整合rest服務(wù)
我們知道,在restful服務(wù)中,服務(wù)端不再直接生成頁(yè)面了,而是只返回?cái)?shù)據(jù)(json),客戶端渲染,而我們前面的例子已經(jīng)知道了springsecurity默認(rèn)也是直接生成整個(gè)頁(yè)面的,所以這里的關(guān)鍵
就成了我們需要自己定義返回?cái)?shù)據(jù)形式了,主要是錯(cuò)誤處理,那我們接下來(lái)就開(kāi)始實(shí)踐。
實(shí)踐
pom
新建一個(gè)springboot項(xiàng)目,pom文件如下:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-security-demos</artifactId>
<groupId>cn.jsbintask</groupId>
<version>1.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>security-jwt-restful</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
</dependencies>
</project>
數(shù)據(jù)源
定義數(shù)據(jù)源,application.yml文件以及sql腳本如下:
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: jason
url: jdbc:mysql://localhost:3306/springsecurity_demos?useSSL=false
jpa:
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
logging:
level:
org.springframework.*: debug
sql腳本如下,本例我們了簡(jiǎn)單,密碼使用明文:
CREATE DATABASE springsecurity_demos;
USE springsecurity_demos;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`role_name` varchar(16) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`description` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES (1, 'user', 'this is a user role.');
INSERT INTO `role` VALUES (2, 'admin', 'this is a admin role.');
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(16) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`password` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`age` int(11) NULL DEFAULT NULL,
`address` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'jsbintask', '123456', 22, 'China, Wuhan', 1);
SET FOREIGN_KEY_CHECKS = 1;
接著定義實(shí)體類domain,repository等,參考之前的博客,值得注意的是,此處我們的AuthUser稍有不同:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthUser implements UserDetails {
private String username;
private String password;
private List<Role> roles;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.isEmpty() ? Collections.EMPTY_LIST :
// ROLE_ 是springsecurity對(duì)于角色的默認(rèn)前綴,如果不加,驗(yàn)證會(huì)失敗
(roles.parallelStream().map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleName())).collect(Collectors.toList()));
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
配置securityConfig
我們和前面章節(jié)一樣,定義一個(gè)SecurityConfig類,繼承WebSecurityConfigurerAdapter:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
接下來(lái)就是開(kāi)始詳細(xì)配置了,前面我們學(xué)習(xí)基礎(chǔ)配置的時(shí)候已經(jīng)知道,配置的重點(diǎn)是自定義
UserDetailsService,并且我們通過(guò)源碼的方式知道了是UsernamePasswordAuthenticationFilter幫我們認(rèn)證了用戶,并且生成了頁(yè)面,轉(zhuǎn)發(fā)等等。
但是!在本例中,因?yàn)槲覀兪钦蟫estful服務(wù),返回的都是json數(shù)據(jù),所以我們不再需要這些。而既然要返回json數(shù)據(jù),那我們先定義好一個(gè)通用的數(shù)據(jù)類,ResultVO:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResultVO<T> {
// code請(qǐng)自己定義,例如 10成功,20需要登陸 30無(wú)權(quán)限等等
private Integer code;
private String msg;
private T data;
}
而既然我們的服務(wù)端是生成jwt,那現(xiàn)在登錄的接口就成了生成jwt了,我們首先編寫JwtUtil工具類:
public class JwtUtil {
private static final String secret = "jsbintask@gmail.com";
public static String generateToken(String username, List<Role> roles) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", roles.parallelStream().map(Role::getRoleName).collect(Collectors.joining(",")));
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
//創(chuàng)建時(shí)間
.setIssuedAt(new Date())
//過(guò)期時(shí)間,我們?cè)O(shè)置為 五分鐘
.setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
//簽名,通過(guò)密鑰保證安全性
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public static AuthUser parseToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
String username = claims.getSubject();
String roles = (String) claims.get("roles");
//因?yàn)樯傻臅r(shí)候沒(méi)有放入密碼,所以不需要密碼
return new AuthUser(username, null, Arrays.stream(roles.split(",")).map(name -> {
Role role = new Role();
role.setRoleName(name);
return role;
}).collect(Collectors.toList()));
}
}
編寫生成token的controller,UserController:
@RestController
public class UserController {
@Resource
private UserRepository userRepository;
@Resource
private RoleRepository roleRepository;
@GetMapping("/token")
public ResultVO login(String username, String password) {
User user = userRepository.findByUsername(username);
if (user == null || !user.getPassword().equals(password)) {
ResultVO<Object> result = new ResultVO<>();
result.setCode(10);
result.setMsg("用戶名或密碼錯(cuò)誤");
return result;
}
ResultVO<Object> success = new ResultVO<>();
//用戶名密碼正確,生成token給客戶端
success.setCode(0);
List<Role> roles = Collections.singletonList(roleRepository.findById(user.getId()).get());
success.setData(JwtUtil.generateToken(username, roles));
return success;
}
}
我們?cè)诙x一個(gè)如果異常處理,用于處理請(qǐng)求沒(méi)有攜帶token以及一個(gè)禁止訪問(wèn)處理器,用于返回沒(méi)有權(quán)限的用戶并且全部作為bean
TokenExceptionHandler:
@Component
public class TokenExceptionHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 直接返回 json錯(cuò)誤
ResultVO<Object> result = new ResultVO<>();
//20,標(biāo)識(shí)沒(méi)有token
result.setCode(20);
result.setMsg("請(qǐng)求無(wú)效,沒(méi)有有效token");
ObjectMapper objectMapper = new ObjectMapper();
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
AccessDeniedHandler:
@Component
public class AccessDeniedHandler implements org.springframework.security.web.access.AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 返回我們的自定義json
ObjectMapper objectMapper = new ObjectMapper();
ResultVO<Object> result = new ResultVO<>();
//50,標(biāo)識(shí)有token,但是該用戶沒(méi)有權(quán)限
result.setCode(50);
result.setMsg("請(qǐng)求無(wú)效,沒(méi)有有效token");
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
除此之外,前面我們已經(jīng)通過(guò)debug(第四章)得知,在UsernamePasswordAuthenticationFilter中,springsecurity加入了SecurityContext,既然我們現(xiàn)在不用了,那我們要自己定義攔截器并且加入
securityContext以便springsecurity作權(quán)限處理,所以我們自定義攔截器:
@Component
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");
//獲取token,并且解析token,如果解析成功,則放入 SecurityContext
if (token != null) {
try {
AuthUser authUser = JwtUtil.parseToken(token);
//todo: 如果此處不放心解析出來(lái)的 authuser,可以再?gòu)臄?shù)據(jù)庫(kù)查一次,驗(yàn)證用戶身份:
//解析成功
if (SecurityContextHolder.getContext().getAuthentication() == null) {
//我們依然使用原來(lái)filter中的token對(duì)象
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
} catch (Exception e) {
logger.info("解析失敗,可能是偽造的或者該token已經(jīng)失效了(我們?cè)O(shè)置失效5分鐘)。");
}
}
filterChain.doFilter(request, response);
}
}
前面已經(jīng)說(shuō)了,我們不再需要UserDetailsService和UsernamePasswordAuthenticationFilter,當(dāng)然密碼加密器也不再需要,那現(xiàn)在我們的websecurity如下:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private TokenExceptionHandler tokenExceptionHandler;
@Resource
private AccessDeniedHandler accessDeniedHandler;
@Resource
private JwtTokenFilter jwtTokenFilter;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 因?yàn)槲覀兊膖oken是無(wú)狀態(tài)的,不需要跨站保護(hù)
.csrf().disable()
// 添加異常處理,以及訪問(wèn)禁止(無(wú)權(quán)限)處理
.exceptionHandling().authenticationEntryPoint(tokenExceptionHandler).accessDeniedHandler(accessDeniedHandler).and()
// 我們不再需要session了
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
//定義攔截頁(yè)面,所有api全部需要認(rèn)證
.authorizeRequests()
.anyRequest().authenticated();
//最后,我們定義 filter,用來(lái)替換原來(lái)的UsernamePasswordAuthenticationFilter
httpSecurity.addFilterAt(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
// 讓我們獲取 token的api不走springsecurity的過(guò)濾器,大道開(kāi)放
.antMatchers(HttpMethod.GET, "/token");
}
}
值得注意的是,我們把 /token(登錄)這個(gè)api完全開(kāi)放了,因?yàn)樗静恍枰哌^(guò)濾器鏈,接下來(lái)我們?cè)賹懸粋€(gè)Controller用于測(cè)試,叫PermissionController:
@RestController
@RequestMapping
public class PermissionController {
@GetMapping("/permission")
public ResultVO loginTest(@AuthenticationPrincipal AuthUser authUser) {
ResultVO<String> resultVO = new ResultVO<>();
resultVO.setCode(0);
resultVO.setData("你成功訪問(wèn)了該api,這代表你已經(jīng)登錄,你是: " + authUser);
return resultVO;
}
@GetMapping("/permission")
@PreAuthorize("hasRole('user')")
public ResultVO loginTest() {
ResultVO<String> resultVO = new ResultVO<>();
resultVO.setCode(0);
resultVO.setData("你成功訪問(wèn)了需要有 user 角色的api。");
return resultVO;
}
}
該controller一共兩個(gè)api,一個(gè)為登錄可訪問(wèn),一個(gè)為登陸后還需要有 user角色的api。
測(cè)試
接下來(lái)就是見(jiàn)證奇跡的時(shí)候了,首先我們直接訪問(wèn) /8080,我們猜想應(yīng)該是錯(cuò)誤code=20,因?yàn)樗衋pi都需要token,結(jié)果如下:
果然和我們想的一樣(亂碼原因是我們手動(dòng)new的jackson對(duì)象,沒(méi)有設(shè)置編碼),同時(shí)我們debug JwtTokenFilter,果然在過(guò)濾器鏈中找到了我們的filter,并且替換了原來(lái)的UsernamePasswordAuthenticationFilter
不見(jiàn)了:
接下來(lái)我們登錄,訪問(wèn) http://localhost:8080/token?username=jsbintask&password=12345,用戶名
密碼根據(jù)自己數(shù)據(jù)庫(kù)調(diào)整,結(jié)果如下:用戶名密碼錯(cuò)誤:
然后我們輸入正確的用戶名密碼,如下:
果然,我們得到了code=0,代表成功了,并且拿到了token,接下來(lái)我們用該token去訪問(wèn)我們的PermissionController:
訪問(wèn)不需要身份的 /normal:
成功!并且看到了我們的信息。
接下來(lái)訪問(wèn) /role,需要user身份,我們創(chuàng)建一個(gè)沒(méi)有該身份的用戶:
INSERT INTO `springsecurity_demos`.`user`(`id`, `address`, `age`, `password`, `role_id`, `username`) VALUES (2, 'Wuhan, China', 22, '123456', 22, 'jsbintask2');
同樣登陸后,訪問(wèn):
果然,我們得到了我們自定義的json數(shù)據(jù) coe=50,接下來(lái)我們切換有該身份的用戶:
果然成功了!,這樣,我們的目的就全部達(dá)到了。 restful服務(wù)也完全整合好了
總結(jié)
本章,我們首先介紹了jwt是什么,以及和傳統(tǒng)session的不同,然后我們通過(guò)一個(gè)完整的例子展示了前面章節(jié)所學(xué)以及在實(shí)際中該如何保護(hù)我們的 api。并且成功的達(dá)到了我們的目的。
本章源碼:https://github.com/jsbintask22/spring-security-demos.git
到此,我們的spingsecurity系列的文章就寫完了,同系列文章地址:https://jsbintask.cn/tags/springsecurity/
本文原創(chuàng)地址:https://jsbintask.cn/2019/01/21/springsecurity-restful/#more,未經(jīng)允許,禁止轉(zhuǎn)載。