案例分析
在本案例中有3个工程,分别为 eureka- server、auth- service和user- service其中auth- service和 user-service向 eureka- server注册服务。auth- service负责授权,授权需要用户提供客户端的clientId和 password,以及授权用户的 username和 password这些信息准备无误之后,auth- - service返回JWT,该JWT包含了用户的基本信息和权限点信息,并通过RSA加密。 user-service作为资源服务,它的资源已经被保护起来了,需要相应的权限才能访问。user-service 服务得到用户请求的JWT后,先通过公钥解密JWT,得到该JWT对应的用户的信息和用户的权限信息,再判断该用户是否有权限访问该资源。
其中,在user-service服务的登录API接口(登录API接口不受保护)中,当用户名和密码验证正确后,通过远程调用向auth-service获取JWT,并返回JWT给用户。用户获取到JWT之后,以后的每次请求都需要在请求头中传递该JWT,从而资源服务能够根据JWT来进行权限验证。架构图如图:
编写主Maven工程
<?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">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>oauth2-jwt-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.3.RELEASE</version>
<relativePath/>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Dalston.SR1</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
编写Eureka Server:
在主Maven工程下,创建一个eureka-server的Module工程,作为服务注册中心的工程。在工程的pom文件引入相应的依赖,包括继承了主Maven工程的pom 文件, 并引入EurekaServer的起步依赖,代码如下:
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka-server</artifactId>
</dependency>
</dependencies>
在工程的配置文件application.yml中,配置程序的端口号为8761,并配置不自注册,配置代码如下:
server:
port: 8761
spring:
application:
name: eureka-server
eureka:
client:
fetch-registry: false
register-with-eureka: false
service-url:
defaultZone: http://localhost:${server.port}/eureka/
启动类:
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
编写Uaa授权服务
1.引入依赖:
在主Maven工程下新建一个 Module工程,取名为uaa-service。工程的pom文件继承了主Maven工程的pom文件,并引入工程所需的依赖,包括连接数据库的依赖mysql-connectorjava和JPA的起步依赖spring-boot-starter-data-jpa、Web 的起步依赖spring-boot-starter- web、Eureka客户端的起步依赖spring-cloud-starter eureka,以及Spring Cloud OAuth2 的起步依赖spring-cloud-starter- oauth2。
其中,Spring Cloud 0Auth2的起步依赖包含了Spring Security 0Auth2和Spring SecurityJWT的等依赖,代码如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</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>
</dependencies>
2.配置文件:
在程序的配置文件aplication.yml中配置程序的名称为uaa-service, 端口号为9999,以及连接数据库驱动、JPA 的配置和服务的注册地址,代码如下:
server:
port: 9999
spring:
application:
name: uaa-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/auth?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
username: root
password: 123456
jpa:
hibernate:
ddl-auto: update
show-sql: true
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
3.配置Spring Security:
uaa-service服务对外提供获取JWT的API接口,uaa-service 服务是一个授权服务器,同时也是资源服务器,需要配置该服务的SpringSecurity,配置代码如下:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
})
.and()
.authorizeRequests()
.antMatchers("/**").authenticated()
.and()
.httpBasic();
}
@Autowired
UserServiceDetail userServiceDetail;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userServiceDetail)
.passwordEncoder(new BCryptPasswordEncoder());
}
}
在上面的配置类中,通过@EnableWebSecurity注解开启Web资源的保护功能。在configure(HttpSecurity http)方法中配置所有的请求都需要验证,如果请求验证不通过,则重定位到401的界面。在configure(AuthenticationManagerBuilder auth)方法中配置验证的用户信息源、密码加密的策略。向IoC容器注入AuthenticationManager对象的Bean,该Bean在OAuth2的配置中使用,因为只有在0Auth2中配置了AuthenticationManager,密码类型的验证才会开启。在本案例中,采用的是密码类型的验证。
采用BCryptPasswordEncoder对密码进行加密,在创建用户时,密码加密也必须使用这个类。使用了UserServiceDetail 这个类,实现了UserDetailsService接口,代码如下:
@Service
public class UserServiceDetail implements UserDetailsService {
@Autowired
private UserDao userDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userDao.findByUsername(username);
}
}
UserDao:
public interface UserDao extends JpaRepository<User, Long> {
User findByUsername(String username);
}
4.配置Authorization Server:
@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("user-service")
.secret("123456")
.scopes("service")
.authorizedGrantTypes("refresh_token", "password")
.accessTokenValiditySeconds(3600);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore()).tokenEnhancer(jwtTokenEnhancer())
.authenticationManager(authenticationManager);
}
@Autowired
@Qualifier("authenticationManagerBean")
private AuthenticationManager authenticationManager;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtTokenEnhancer());
}
@Bean
protected JwtAccessTokenConverter jwtTokenEnhancer() {
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("fzp-jwt.jks"), "fzp123".toCharArray());
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("fzp-jwt"));
return converter;
}
}
在上面的配置代码中,OAuth2Config类继承了AuthorizationServerConfigurerAdapter类,并在OAuth2Config类加上@EnableAuthorizationServer注解,开启Authorization Server的功能。作为Authorization Server 需要配置两个选项,即ClientDetailsServiceConfigurer 和AuthorizationServerEndpointsConfigurer。
其中,ClientDetailsServiceConfigurer配置了客户端的一些基本信息,clients.inMemory()
方法是将客户端的信息存储在内存中,.withClient( "user- service")
方法创建了一一个 ClientId 为“user-service的客户端,.authorizedGrantTypes("refresh_ token", "password")
方 法配置类验证类型为refresh token和password, .scopes("service ")
方法配置了客户端域为“service”, .accessTokenValiditySeconds(3600)
方法配置了Token的过期时间为3600秒。
AuthorizationServerEndpointsConfigurer配置了tokenStore 和authenticationManager.其中tokenStore使用JwtTokenStore,JwtTokenStore 并没有做任何存储,tokenStore 需要一个JwtAccessTokenConverter对象,该对象用于Token转换。本案例中使用了非对称性加密RSA对JWT进行加密。
authenticationManager需要配置AuthenticationManager 这个Bean, 这个Bean 来源于WebSecurityConfigurerAdapter的配置,只有配置了这个Bean才会开启密码类型的验证。
5.生成jks文件:
在AuthorizationServerEndpointsConfigurer的配置中,配置JwtTokenStore 时需要使用jks文件作为Token加密的秘钥。那么jks文件是怎样生成的呢?在本案例中,jks文件是使用Javakeytool生成的,在生成jks 文件之前需要保证Jdk已经安装。打开计算机终端,输入以下命令:
keytool -genkeypair -alias fzp-jwt -validity 3650 -keyalg RSA -dname "CN=jwt,OU=jtw,O=jtw,L=zurich,S=zurich,C=CH" -keypass fzp123 -keystore fzp-jwt.jks -storepass fzp123
在上面的命令中,-alias 选项为别名,-keypass 和-storepass为密码选项,-validity 为配置jks文件的过期时间(单位:天)。
获取的jks文件作为私钥,只允许Uaa服务持有,并用作加密JWT。那么user-serive这样的公钥。获取jks文件的公钥命令如下:
keytool -list -rfc --keystore fzp-jwt.jks | openssl x509 -inform pem -pubkey
在计算机终端输入上面的命令,提示需要密码,本例的密码为“fzp123”, 输入即可,显示的公钥信息如下:
新建-一个public.cert 文件,将上面的公钥信息复制到public.cert 文件中并保存。并将public.cert文件放在资源服务的工程的Resource目录下。到目前为止,Uaa授权服务已经搭建完毕。
需要注意的是,Maven 在项目编译时,可能会将jks文件编译,导致jks文件乱码,最后不可用。需要在工程的pom文件中添加以下内容:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<nonFilteredFileExtensions>
<nonFilteredFileExtension>cert</nonFilteredFileExtension>
<nonFilteredFileExtension>jks</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
</plugins>
</build>
编写user-service资源服务:
1.依赖:
user-service工程的pom文件继承了主Maven工程的pom文件。在user- service工程的pom文件中引入Web功能的起步依赖spring botater-web、OAuth2 的起步依赖springecloud-starter-oauth2、数据库连接依赖mysl-connectorjava、 JPA的起步依赖spring bootateredataja、Eureka的起步依赖spring-loud-starter-eureka和声明式调用Feign和Hystrix的起步依赖。user-service工程的 pom文件代码如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</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>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-feign</artifactId>
</dependency>
</dependencies>
2. 配置文件:
在工程的配置文件application.yml 中,配置程序名为user-service, 端口号为9090,服务的注册地址为htp://localhost:/8761/eureka/, 以及连接的数据库的地址、用户名、密码和JPA的相关配置。另外,需要配置feign.hystrix.enable为true,即开启Feign的Hystrix功能。完整的配置代码如下:
server:
port: 9090
spring:
application:
name: user-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/auth?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
username: root
password: 123456
jpa:
hibernate:
ddl-auto: update
show-sql: true
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
feign:
hystrix:
enabled: true
3. 配置Resource Server:
在配置Resource Server之前,需要注入JwtTokenStore类型的Bean。建一个 JwtConfig类,加上@Configuration注解,开启配置文件的功能。JwtTokenStore 类型的Bean 需要配置- -个JwtAccessTokenConverter类型的Bean,该Bean用作JWT转换器。JwtAccessTokenConverter需要设置VerifierKey, VerifierKey 为公钥,存放在Resource 目录下的public.cert 文件中。JwtConfig类的代码如下:
@Configuration
public class JwtConfig {
@Autowired
JwtAccessTokenConverter jwtAccessTokenConverter;
@Bean
@Qualifier("tokenStore")
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter);
}
@Bean
protected JwtAccessTokenConverter jwtTokenEnhancer() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
Resource resource = new ClassPathResource("public.cert");
String publicKey = null;
try {
publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
} catch (IOException e) {
e.printStackTrace();
}
converter.setVerifierKey(publicKey);
return converter;
}
}
然后配置Resource Server, 新建一-个ResourceServerConfig 的类,该类继承了Resource-ServerConfigurerAdapter类,在ResourceServerConfig类上加@EnableResourceServer注解,开启Resource Server功能。作为Resource Server,需要配置HttpSecurity和ResourceServerSecurityConfigurer这两个选项。
HttpSecurity 配置了哪些请求需要验证,哪些请求不需要验证。在本案例中,“/user/login" (登录)和“/user/register" (注册)两个API接口不需要验证,其他请求都需要验证。ResourceServerSecurityConfigurer需要配置tokenStore, tokenStore为之前注入IoC容器中的tokenStore。代码如下:
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
TokenStore tokenStore;
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/user/login", "/user/register").permitAll()
.antMatchers("/**").authenticated();
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(tokenStore);
}
}
4. 配置Spring Security:
新建一个配置类GlobalMethodSecurityConfig, 在此类中通过@EnableGlobalMethodSecurity(prePostEnabled = true)注解开启方法级别的安全验证。代码如下:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class GlobalMethodSecurityConfig {
}
5. 编写用户注册接口:
这里用到了User和Role两个实体类,这两个类的代码和上面是一样的,在此不再重复。Dao 层的UserDao类继承了JpaRepository 类,并有一一个根据用户名获取用户的方法,代码如下:
public interface UserDao extends JpaRepository<User, Long> {
User findByUsername(String username);
}
Service层的UserService写一一个插入用户的方法,代码如下:
@Service
public class UserServiceDetail {
@Autowired
UserDao userDao;
public User insertUser(String username, String password) {
User u = new User();
u.setUsername(username);
u.setPassword(BPwdEnchoderUtil.BCrytPassword(password));
return userDao.save(u);
}
}
在UserServiceDetail类中使用到了工具类BPwdEncoderUtil,其中BCryptPasswordEncoder是Spring Security的加密类,BPwdEncoderUtil 类的代码如下:
public class BPwdEnchoderUtil {
private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
public static String BCrytPassword(String password) {
return encoder.encode(password);
}
public static boolean matches(CharSequence rawPassword, String encodedPassword) {
return encoder.matches(rawPassword, encodedPassword);
}
}
在Web层,在UserController中写一个注册的API接口“/user/register”, 代码如下:
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
UserServiceDetail userServiceDetail;
@PostMapping("/register")
public User postUser(String username, String password) {
//省略参数判断...
return userServiceDetail.insertUser(username, password);
}
}
启动所有工程:注册一个账号:
curl -d "username=tom&password=123456" "localhost:9090/user/register"
返回信息:
{"id":4,"username":"tom","password":"$2a$10$hf/bQViPFaAH6I8qblTNLu2zaIVs/9q6.IEHbfDNmv.kLLByCXPlS","authorities":null,"enabled":true,"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true}%
使用postman:
6.编写用户登录接口:
在Service层中,在UserServiceDetail中添加一一个login ( 登录)方法,代码如下:
@Service
public class UserServiceDetail {
@Autowired
UserDao userDao;
public User insertUser(String username, String password) {
User u = new User();
u.setUsername(username);
u.setPassword(BPwdEnchoderUtil.BCrytPassword(password));
return userDao.save(u);
}
@Autowired
AuthServiceClient authServiceClient;
public UserLoginDTO login(String username, String password) {
User user = userDao.findByUsername(username);
if (null == user) {
throw new UserLoginException("error username");
}
if (!BPwdEnchoderUtil.matches(password, user.getPassword())) {
throw new UserLoginException("error password");
}
//user-service:123456
JWT jwt = authServiceClient.getToken("Basic dXNlci1zZXJ2aWNlOjEyMzQ1Ng==", "password", username, password);
if (jwt == null) {
throw new UserLoginException("error internal");
}
UserLoginDTO userLoginDTO = new UserLoginDTO();
userLoginDTO.setJwt(jwt);
userLoginDTO.setUser(user);
return userLoginDTO;
}
}
其中,AuthServiceClient为Feign的客户端,所以需要程序的启动类UserServiceApplication通过@EnableFeignClient开启Feign客户端的功能:
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
AuthServiceClient通过向uaa-service 服务远程调用“/oauth/token” API 接口,获取JWT。在“/oauth/token"API接口中需要在请求头传入Authorization信息,并需要传请求参数认证类型grant type、用户名username和密码password,代码:
@FeignClient(value = "uaa-service", fallback = AuthServiceHystrix.class)
public interface AuthServiceClient {
@PostMapping("/oauth/token")
JWT getToken(@RequestHeader("AUthorization")String authorization, @RequestParam("grant_type")String type,
@RequestParam("username")String username, @RequestParam("password")String password);
}
其中,AuthServiceHystrix为AuthServiceClient的熔断器:
@Component
public class AuthServiceHystrix implements AuthServiceClient{
@Override
public JWT getToken(String authorization, String type, String username, String password) {
return null;
}
}
JWT为一个JavaBean,它包含了access_toke、token_type和refresh_token等信息:
public class JWT {
private String access_token;
private String token_type;
private String refresh_toke;
private int expires_in;
private String scope;
private String jti;
//省略getter setter...
}
UserLoginDTO包含了一个User和一个JWT对象,用于返回数据的实体:
public class UserLoginDTO {
private JWT jwt;
private User user;
//...
}
登录异常类UserLoginException,继承自RuntimeException:
public class UserLoginException extends RuntimeException {
public UserLoginException(String message) {
super(message);
}
}
异常统一处理类为ExceptionHandle 类,在该类中加上@ControllerAdvice注解表明该类是一个异常统一处理类。 通过@ExceptionHandler注解配置了统一处理 UserLoginException类的异常方法,统一返回了异常的message信息,代码如下:
@ControllerAdvice
@ResponseBody
public class ExceptionHandle {
@ExceptionHandler(UserLoginException.class)
public ResponseEntity<String> handleException(Exception e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.OK);
}
}
在Web层的UserController类写一个登录的API接口"/user/login":
@PostMapping("/login")
public UserLoginDTO login(@RequestParam("username")String username, @RequestParam("password")String password) {
//省略参数判断...
return userServiceDetail.login(username, password);
}
在“/user/login"API接口中,需要的请求参数为用户名和密码。首先会根据用户名查询数据库,获取用户,如果用户存在, 判断密码是否正确。如果密码正确,通过Feign客户端远程调用uaa-service,获取JWT,获取成功,将用户和JWT封装成UserLoginDTo对象返回。现在使用Curl调用登录API接口,执行命令如下:
curl user-service:123456@localhost:9999/oauth/token -d grant_type=password -d username=tom -d password=123456
测试:
编写一个"/foo"的API接口,该API接口需要"ROLE_ADMIN"权限才能访问:
@RestController
@RequestMapping("/foo")
public class WebController {
@GetMapping()
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public String getFoo() {
return "i'm foo, " + UUID.randomUUID().toString();
}
}
以username为“tom”,密码为“123456”登录,登录成功后返回了JWT对象,JWT中有一个access_ token 的字符串。将该Token放在请求头重中,进行请求,如下:
从上面的返回信息可知,该用户没有权限访问该API接口。这是正常的,因为新注册的“tom”这个用户并没有“ROLE_ADMIN"权限。为了方便演示,现在给“ tom”这个用户赋予“ROLE_ADMIN"的权限,直接在数据库中插入以下数据,数据库脚本如下:
insert into `role` values('1','ROLE_USER'),('2','ROLE_ADMIN');
insert into `user_role` values('5', '2');
重新登录刷新token,再重新访问"/foo":
总结
在本案例中,用户通过登录接口来获取授权服务的Token。用户获取Token成功后,在以后每次访问资源服务的请求中都需要携带该Token。资源服务通过公钥解密Token,解密成功后可以获取用户信息和权限信息,从而判断该Token所对应的用户是谁,具有什么权限。
这个架构的优点在于,一次获取Token,多次使用,不再每次询问Uaa服务该Token所对应的用户信息和用户的权限信息。这个架构也有缺点,例如一旦用户的权限发生了改变,该Token中存储的权限信息并没有改变,需要重新登录获取新的Token。就算重新获取了Token,如果原来的Token没有过期,仍然是可以使用的,所以需要根据具体的业务场景来设置Token的过期时间
。
一种改进方式是将登录成功后获取的Token缓存在网关上,如果用户的权限更改,将网关上缓存的Token删除。当请求经过网关,判断请求的Token在缓存中是否存在,如果缓存中不存在该Token,则提示用户重新登录。