通過 Spring Security + OAuth2 認證和鑒權,每次請求都需要經過 OAuth Server 驗證當前 token 的合法性,並且需要查詢該 token 對應的用戶權限,在高並發場景下會存在性能瓶頸。使用 JWT 的方式,OAuth Server 只驗證一次,用戶所有信息 (包括權限) 包含在返回的 JWT 中
准備工作
生成公鑰、私鑰
私鑰
在控制台輸入命令:
keytool -genkeypair -alias spring-jwt -validity 3650 -keyalg RSA -dname "CN=Victor,OU=Karonda,O=Karonda,L=Shenzhen,S=Guangdong,C=CN" -keypass abc123 -storepass abc123 -keystore spring-jwt.jks
各個參數的含義,可以通過命令查看:
keytool -genkeypair -help
其中 DName 各個參數代表的意義見: X.500 Distinguished Names
公鑰
在控制台輸入命令:
keytool -list -rfc --keystore spring-jwt.jks | openssl x509 -inform pem -pubkey
會提示輸入密碼,密碼為生成私鑰命令里設置的密碼
本文生成的公鑰:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2HvMsVqrx60ESp30Ymx7
3Ce2h24QvG9rciDPl8+SxXRz79akmdRCB4HFhBb655aVAnQMj4SGzKcMyofOUt3o
X9tOPz3Y/B/D5viI3cNPYinyFVMawganROsM1meTFR1SPpL/kZUZqLm9pc8lpgat
LtU73ryioVe7FFndce6ZwTe24L4rK0jzseQ24FxoEQ+g0B1DCXZ4Gi9PwBpxWL6W
AG+/NEFFtOGtIJSIwCYzhGqDfyNaOt7JXYwGiWgh0npO3JVvgQVXBW9AdpT5JVSb
ScYktkqY3o0htsSueyne+FbS+OwBVaBewcswPVbEwa6dxtb0vBsp3pNiSdg7rDea
1QIDAQAB
-----END PUBLIC KEY-----
新建 public.cert 文件保存上面生成的公鑰 (要包含公鑰的完整信息,即 BEGIN PUBLIC KEY 和 END PUBLIC KEY 部分也要包含在文件中)
Windows 系統需要先安裝 OpenSSL: 下載鏈接
將私鑰和公鑰分別拷貝到 oauth2-server 和 eureka-client 的 resources 目錄下
並在 oauth2-server 和 eureka-client 的 pom 添加配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<nonFilteredFileExtensions>
<nonFilteredFileExtension>cert</nonFilteredFileExtension>
<nonFilteredFileExtension>jks</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
因為密鑰文件不需要編譯
oauth2-server
修改 Authorization Server 配置
@Configuration
@EnableAuthorizationServer // 開啟授權服務
@EnableResourceServer // 需要對外暴露獲取和驗證 Token 的接口,所以也是一個資源服務
public class OAuth2Config extends AuthorizationServerConfigurerAdapter{
@Autowired
private AuthenticationManager authenticationManager;
@Override
// 配置客戶端信息
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory() // 將客戶端的信息存儲在內存中
.withClient("eureka-client") // 客戶端
.secret("123456") // 客戶端密碼
.authorizedGrantTypes("client_credentials", "refresh_token", "password")
.accessTokenValiditySeconds(3600) // 設置 token 過期時間
.scopes("server");
}
@Override
// 配置授權 token 的節點和 token 服務
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore()) // token 的存儲方式
.authenticationManager(authenticationManager) // 開啟密碼驗證,來源於 WebSecurityConfigurerAdapter
// .userDetailsService(userServiceDetail); // 讀取驗證用戶的信息
.tokenEnhancer(jwtTokenEnhancer());
}
@Bean
public TokenStore tokenStore() {
// return new InMemoryTokenStore();
// return new JdbcTokenStore(dataSource);
return new JwtTokenStore(jwtTokenEnhancer());
}
@Bean
protected JwtAccessTokenConverter jwtTokenEnhancer(){
KeyStoreKeyFactory keyStoreKeyFactory =
new KeyStoreKeyFactory(new ClassPathResource("spring-jwt.jks")
, "abc123".toCharArray()); // abc123 為 password
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("spring-jwt")); // spring-jwt 為 alias
return converter;
}
}
eureka-client
上一篇文章中的 OAuth2 Client 配置本文用不到,要移除
Resource Server 配置
配置 JWT 轉換器
@Configuration
public class JwtConfig {
@Autowired
JwtAccessTokenConverter jwtAccessTokenConverter;
@Bean
public TokenStore tokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter);
}
@Bean
protected JwtAccessTokenConverter jwtTokenEnhancer(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
Resource resource = new ClassPathResource("public.cert"); // 公鑰
String publicKey;
try{
publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
}catch (IOException e){
throw new RuntimeException(e);
}
converter.setVerifierKey(publicKey);
return converter;
}
}
修改 Resource Server 配置
@Configuration
@EnableResourceServer // 開啟資源服務
@EnableGlobalMethodSecurity(prePostEnabled = true) // 開啟方法級別上的保護
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {
@Autowired
TokenStore tokenStore;
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/user/login", "/user/register").permitAll()
.anyRequest().authenticated();
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(tokenStore);
}
}
JWT 類
public class JWT {
private String access_token;
private String token_type;
private String refresh_token;
private int expires_in;
private String scope;
private String jti;
public String getAccess_token() {
return access_token;
}
public void setAccess_token(String access_token) {
this.access_token = access_token;
}
public String getToken_type() {
return token_type;
}
public void setToken_type(String token_type) {
this.token_type = token_type;
}
public String getRefresh_token() {
return refresh_token;
}
public void setRefresh_token(String refresh_token) {
this.refresh_token = refresh_token;
}
public int getExpires_in() {
return expires_in;
}
public void setExpires_in(int expires_in) {
this.expires_in = expires_in;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getJti() {
return jti;
}
public void setJti(String jti) {
this.jti = jti;
}
@Override
public String toString() {
return "JWT{" +
"access_token='" + access_token + '\'' +
", token_type='" + token_type + '\'' +
", refresh_token='" + refresh_token + '\'' +
", expires_in=" + expires_in +
", scope='" + scope + '\'' +
", jti='" + jti + '\'' +
'}';
}
}
Feign 客戶端
@FeignClient("oauth2-server")
public interface AuthServiceClient {
@PostMapping("/uaa/oauth/token")
JWT getToken(@RequestHeader(value = "Authorization") String authorization, @RequestParam("grant_type") String type,
@RequestParam("username") String username, @RequestParam("password") String password);
}
同時需要在啟動類添加注解:
@EnableFeignClients
DTO 及異常處理
public class UserLoginDTO {
private JWT jwt;
private User user;
public JWT getJwt() {
return jwt;
}
public void setJwt(JWT jwt) {
this.jwt = jwt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
}
public class UserLoginException extends RuntimeException {
public UserLoginException(String message){
super(message);
}
}
@ControllerAdvice // 表明該類是異常統一處理類
@ResponseBody
public class ExceptionHandle {
@ExceptionHandler(UserLoginException.class)
public ResponseEntity<String> handleException(Exception e){
return new ResponseEntity(e.getMessage(), HttpStatus.OK);
}
}
service & controller
添加登錄方法
@Override
public UserLoginDTO login(String username, String password) {
User user = userDao.findByUsername(username);
if(null == user){
throw new UserLoginException("error username");
}
if(!password.equals(user.getPassword())){
throw new UserLoginException("erro password");
}
JWT jwt = authServiceClient.getToken("Basic ZXVyZWthLWNsaWVudDoxMjM0NTY="
, "password", username, password); // ZXVyZWthLWNsaWVudDoxMjM0NTY= 為 eureka-client:123456 Base64 加密后的值
if(null == jwt){
throw new UserLoginException("error internal");
}
UserLoginDTO userLoginDTO = new UserLoginDTO();
userLoginDTO.setJwt(jwt);
userLoginDTO.setUser(user);
return userLoginDTO;
}
@RequestMapping(value = "/login", method = RequestMethod.POST)
public UserLoginDTO login(@RequestParam("username") String username
, @RequestParam("password") String password){
return userService.login(username, password);
}
測試
- 啟動 eureka-server
- 啟動 oauth2-server
- 啟動 config-server
- 啟動 eureka-client
先取消授權:
DELETE FROM user_role WHERE user_id = 2;
使用 Postman 測試:
用戶登錄
- | POST | localhost:8011/user/login |
Body | ||
- | username | admin |
- | password | 123 |
{
"jwt": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjEyNjUyMzgsInVzZXJfbmFtZSI6ImFkbWluIiwianRpIjoiNTQxMDk3MDItNTU1Yy00NjA4LThkYzYtNzU3NWZjNGIwNGIyIiwiY2xpZW50X2lkIjoiZXVyZWthLWNsaWVudCIsInNjb3BlIjpbInNlcnZlciJdfQ.P6dtT76bFyQ6aF7-v6Vphi3ivLR0x4w739gwmBRGujaRpfDMjwQHCn5REyxEOAKdoxrVT__v73qcb78_8Ovb97L13ztnzdlPmLYzcAkQdMFz78yAjZIp2VtzxZ87Ecmk9f6-bIRlBxS9A24t0y4Tp1gkPITB1vxod0FewAHCsUJQ9WqLNeW9bxzZvy5DtlJlCCY7lOIjfDxlQdXygpwznZ4rIHv-O-eOr2aqcKMLZhdtW7hHsy2JccIUm1ZdpVQfUMD7XzWFAQoZYFLc0oXyVL0nFasOr-Ne1UR1iZYI4cS-ONVLMe78erVb-zRoyTAhEb7Pkyepkwm_Xv23U2CoeA",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjM4NTM2MzgsInVzZXJfbmFtZSI6ImFkbWluIiwianRpIjoiODhjYTQ0NjktMTIyNi00ZTFkLTlhMDktZDZlMTdhOTMyYzAzIiwiY2xpZW50X2lkIjoiZXVyZWthLWNsaWVudCIsInNjb3BlIjpbInNlcnZlciJdLCJhdGkiOiI1NDEwOTcwMi01NTVjLTQ2MDgtOGRjNi03NTc1ZmM0YjA0YjIifQ.RdBDYKhZJz5DntxK_4np1B4phnalT37srjycUUmCHVZ0BB4lEWAIT5YLlY7ZaaVM2AAhbeb1WO1dhlmvtmlkd8W6lowbtMeyMYqrKcbn1tYavLwZDHKWSHGiUW1bXivngwhixCqLwK0AA8Oe-9-ohC-c6G7cRN4r6bWkc4WiadlErg6MS7N6VGdQj26SgPVmTqvVhpm5mnzGJyM66d-kxneHyRjPVli1DFyxuUl8oRCTTFuamybXmD_niWCA-isDgF7loJFV6hMjoow6-3uK9rLthMADIM4YqAp8T8eGsup_7hIICwT7qUhOdzBjwsuX8ond3iu09322LsPEoTTXlg",
"expires_in": 3599,
"scope": "server",
"jti": "54109702-555c-4608-8dc6-7575fc4b04b2"
},
"user": {
"id": 2,
"username": "admin",
"password": "123",
"authorities": [],
"enabled": true,
"credentialsNonExpired": true,
"accountNonExpired": true,
"accountNonLocked": true
}
}
訪問不需要權限的接口
- | GET | localhost:8011/hi?name=Victor |
Headers | ||
- | Authorization | Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjEyNjUyMzgsInVzZXJfbmFtZSI6ImFkbWluIiwianRpIjoiNTQxMDk3MDItNTU1Yy00NjA4LThkYzYtNzU3NWZjNGIwNGIyIiwiY2xpZW50X2lkIjoiZXVyZWthLWNsaWVudCIsInNjb3BlIjpbInNlcnZlciJdfQ.P6dtT76bFyQ6aF7-v6Vphi3ivLR0x4w739gwmBRGujaRpfDMjwQHCn5REyxEOAKdoxrVT__v73qcb78_8Ovb97L13ztnzdlPmLYzcAkQdMFz78yAjZIp2VtzxZ87Ecmk9f6-bIRlBxS9A24t0y4Tp1gkPITB1vxod0FewAHCsUJQ9WqLNeW9bxzZvy5DtlJlCCY7lOIjfDxlQdXygpwznZ4rIHv-O-eOr2aqcKMLZhdtW7hHsy2JccIUm1ZdpVQfUMD7XzWFAQoZYFLc0oXyVL0nFasOr-Ne1UR1iZYI4cS-ONVLMe78erVb-zRoyTAhEb7Pkyepkwm_Xv23U2CoeA |
Hello Victor, from port: 8011, version: 1.0.2
訪問需要權限的接口
- | GET | localhost:8011/hello |
Headers | ||
- | Authorization | Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjEyNjUyMzgsInVzZXJfbmFtZSI6ImFkbWluIiwianRpIjoiNTQxMDk3MDItNTU1Yy00NjA4LThkYzYtNzU3NWZjNGIwNGIyIiwiY2xpZW50X2lkIjoiZXVyZWthLWNsaWVudCIsInNjb3BlIjpbInNlcnZlciJdfQ.P6dtT76bFyQ6aF7-v6Vphi3ivLR0x4w739gwmBRGujaRpfDMjwQHCn5REyxEOAKdoxrVT__v73qcb78_8Ovb97L13ztnzdlPmLYzcAkQdMFz78yAjZIp2VtzxZ87Ecmk9f6-bIRlBxS9A24t0y4Tp1gkPITB1vxod0FewAHCsUJQ9WqLNeW9bxzZvy5DtlJlCCY7lOIjfDxlQdXygpwznZ4rIHv-O-eOr2aqcKMLZhdtW7hHsy2JccIUm1ZdpVQfUMD7XzWFAQoZYFLc0oXyVL0nFasOr-Ne1UR1iZYI4cS-ONVLMe78erVb-zRoyTAhEb7Pkyepkwm_Xv23U2CoeA |
{
"error": "access_denied",
"error_description": "不允許訪問"
}
手動授權:
INSERT INTO user_role (user_id, role_id) VALUES (2, 2);
重新登錄后再次訪問接口
hello!
完整代碼:GitHub
本人 C# 轉 Java 的 newbie, 如有錯誤或不足歡迎指正,謝謝