Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

零壹技術棧發表於2019-02-09

相關

  1. Spring Cloud實戰系列(一) - 服務註冊與發現Eureka

  2. Spring Cloud實戰系列(二) - 客戶端呼叫Rest + Ribbon

  3. Spring Cloud實戰系列(三) - 宣告式客戶端Feign

  4. Spring Cloud實戰系列(四) - 熔斷器Hystrix

  5. Spring Cloud實戰系列(五) - 服務閘道器Zuul

  6. Spring Cloud實戰系列(六) - 分散式配置中心Spring Cloud Config

  7. Spring Cloud實戰系列(七) - 服務鏈路追蹤Spring Cloud Sleuth

  8. Spring Cloud實戰系列(八) - 微服務監控Spring Boot Admin

  9. Spring Cloud實戰系列(九) - 服務認證授權Spring Cloud OAuth 2.0

  10. Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

前言

通過 JWT 配合 Spring Security OAuth2 使用的方式,可以避免 每次請求遠端排程 認證授權服務。資源伺服器 只需要從 授權伺服器 驗證一次,返回 JWT。返回的 JWT 包含了 使用者 的所有資訊,包括 許可權資訊

正文

1. 什麼是JWT

JSON Web TokenJWT)是一種開放的標準(RFC 7519),JWT 定義了一種 緊湊自包含 的標準,旨在將各個主體的資訊包裝為 JSON 物件。主體資訊 是通過 數字簽名 進行 加密驗證 的。經常使用 HMAC 演算法或 RSA公鑰/私鑰非對稱性加密)演算法對 JWT 進行簽名,安全性很高

  • 緊湊型資料體積小,可通過 POST 請求引數HTTP 請求頭 傳送。

  • 自包含JWT 包含了主體的所有資訊,避免了 每個請求 都需要向 Uaa 服務驗證身份,降低了 伺服器的負載

2. JWT的結構

JWT 的結構由三部分組成:Header(頭)、Payload(有效負荷)和 Signature(簽名)。因此 JWT 通常的格式是 xxxxx.yyyyy.zzzzz

2.1. Header

Header 通常是由 兩部分 組成:令牌的 型別(即 JWT)和使用的 演算法型別,如 HMACSHA256RSA。例如:

{
    "typ": "JWT",
    "alg": "HS256"
}
複製程式碼

HeaderBase64 編碼作為 JWT第一部分,不建議在 JWTHeader 中放置 敏感資訊

2.2. Payload

第二部分 PayloadJWT主體內容部分,它包含 宣告 資訊。宣告是關於 使用者其他資料 的宣告。

宣告有三種型別: registeredpublicprivate

  • Registered claimsJWT 提供了一組 預定義 的宣告,它們不是 強制的,但是推薦使用。JWT 指定 七個預設 欄位供選擇:
註冊宣告 欄位含義
iss 發行人
exp 到期時間
sub 主題
aud 使用者
nbf 在此之前不可用
iat 釋出時間
jti 用於標識JWT的ID
  • Public claims:可以隨意定義。

  • Private claims:用於在 同意使用 它們的各方之間 共享資訊,並且不是 註冊的公開的 宣告。

下面是 Payload 部分的一個示例:

{
    "sub": "123456789",
    "name": "John Doe",
    "admin": true
}
複製程式碼

PayloadBase64 編碼作為 JWT第二部分,不建議在 JWTPayload 中放置 敏感資訊

2.3. Signature

要建立簽名部分,需要利用 祕鑰Base64 編碼後的 HeaderPayload 進行 加密,加密演算法的公式如下:

HMACSHA256(
    base64UrlEncode(header) + '.' +
    base64UrlEncode(payload),
    secret
)
複製程式碼

簽名 可以用於驗證 訊息傳遞過程 中有沒有被更改。對於使用 私鑰簽名token,它還可以驗證 JWT傳送方 是否為它所稱的 傳送方

3. JWT的工作方式

客戶端 獲取 JWT 後,對於以後的 每次請求,都不需要再通過 授權服務 來判斷該請求的 使用者 以及該 使用者的許可權。在微服務系統中,可以利用 JWT 實現 單點登入。認證流程圖如下:

Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

4. 案例工程結構

  • eureka-server:作為 註冊服務中心,埠號為 8761。這裡不再演示搭建。

  • auth-service:作為 授權服務授權 需要使用者提供 客戶端client IdClient Secret,以及 授權使用者usernamepassword。這些資訊 準備無誤 之後,auth-service 會返回 JWT,該 JWT 包含了使用者的 基本資訊許可權點資訊,並通過 RSA 私鑰 進行加密。

  • user-service:作為 資源服務,它的 資源 被保護起來,需要相應的 許可權 才能訪問。user-service 服務得到 使用者請求JWT 後,先通過 公鑰 解密 JWT,得到 JWT 對應的 使用者資訊使用者許可權資訊,再通過 Spring Security 判斷該使用者是否有 許可權 訪問該資源。

工程原理示意圖如下:

Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

5. 構建auth-service授權服務

  • 新建一個 auth-service 專案模組,完整的 pom.xml 檔案配置如下:
<?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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.3.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>io.github.ostenant.springcloud</groupId>
    <artifactId>auth-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <name>auth-service</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Dalston.SR1</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-jwt</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-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.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <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>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <!--防止jks檔案被mavne編譯導致不可用-->
            <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>
</project>
複製程式碼
  • 修改 auth-service 的配置檔案 application.yml 檔案如下:
spring:
  application:
    name: auth-service
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/spring-cloud-auth?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
    username: root
    password: 123456
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
server:
  port: 9999
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
複製程式碼
  • auth-service 配置 Spring Security 安全登入管理,用於保護 token 發放驗證 的資源介面。
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserServiceDetail userServiceDetail;

    @Override
    public @Bean AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable() //關閉CSRF
                .exceptionHandling()
                .authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
            .and()
                .authorizeRequests()
                .antMatchers("/**").authenticated()
            .and()
                .httpBasic();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userServiceDetail).passwordEncoder(new BCryptPasswordEncoder());
    }
}
複製程式碼

UserServiceDetail.java

@Service
public class UserServiceDetail implements UserDetailsService {
    @Autowired
    private UserDao userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByUsername(username);
    }
}
複製程式碼

UserRepository.java

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User findByUsername(String username);
}
複製程式碼

實體類 User 和上一篇文章的內容一樣,需要實現 UserDetails 介面,實體類 Role 需要實現 GrantedAuthority 介面。

User.java

@Entity
public class User implements UserDetails, Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false,  unique = true)
    private String username;

    @Column
    private String password;

    @ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
    @JoinTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
            inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
    private List<Role> authorities;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    public void setAuthorities(List<Role> authorities) {
        this.authorities = authorities;
    }

    @Override
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    @Override
    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
複製程式碼

Role.java

@Entity
public class Role implements GrantedAuthority {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    @Override
    public String getAuthority() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return name;
    }
}
複製程式碼
  • 新建一個配置類 OAuth2Config,為 auth-service 配置 認證服務,程式碼如下:
@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
    @Autowired
    @Qualifier("authenticationManagerBean")
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 將客戶端的資訊儲存在記憶體中
        clients.inMemory()
                // 配置一個客戶端
                .withClient("user-service")
                .secret("123456")
                // 配置客戶端的域
                .scopes("service")
                 // 配置驗證型別為refresh_token和password
                .authorizedGrantTypes("refresh_token", "password")
                // 配置token的過期時間為1h
                .accessTokenValiditySeconds(3600 * 1000);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 配置token的儲存方式為JwtTokenStore
        endpoints.tokenStore(tokenStore())
                 // 配置用於JWT私鑰加密的增強器
                 .tokenEnhancer(jwtTokenEnhancer())
                 // 配置安全認證管理
                 .authenticationManager(authenticationManager);
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtTokenEnhancer());
    }

    @Bean
    protected JwtAccessTokenConverter jwtTokenEnhancer() {
        // 配置jks檔案
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("fzp-jwt.jks"), "fzp123".toCharArray());
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyStoreKeyFactory.getKeyPair("fzp-jwt"));
        return converter;
    }
}
複製程式碼
  • 生成用於 Token 加密的 私鑰檔案 fzp-jwt.jks

jks 檔案的生成需要使用 Java keytool 工具,保證 Java 環境變數沒問題,輸入命令如下:

$ keytool -genkeypair -alias fzp-jwt 
          -validity 3650 
          -keyalg RSA 
          -dname "CN=jwt,OU=jtw,O=jwt,L=zurich,S=zurich, C=CH" 
          -keypass fzp123 
          -keystore fzp-jwt.jks 
          -storepass fzp123
複製程式碼

其中,-alias 選項為 別名-keyalg加密演算法-keypass-storepass密碼選項-keystorejks檔名稱-validity 為配置 jks 檔案 過期時間(單位:天)。

生成的 jks 檔案作為 私鑰,只允許 授權服務 所持有,用作 加密生成 JWT。把生成的 jks 檔案放到 auth-service 模組的 src/main/resource 目錄下即可。

  • 生成用於 JWT 解密的 公鑰

對於 user-service 這樣的 資源服務,需要使用 jks公鑰JWT 進行 解密。獲取 jks 檔案的 公鑰 的命令如下:

$ keytool -list -rfc 
          --keystore fzp-jwt.jks | openssl x509 
          -inform pem 
          -pubkey
複製程式碼

這個命令要求安裝 openSSL 下載地址,然後手動把安裝的 openssl.exe 所在目錄配置到 環境變數

輸入密碼 fzp123 後,顯示的資訊很多,只需要提取 PUBLIC KEY,即如下所示:

-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlCFiWbZXIb5kwEaHjW+/ 7J4b+KzXZffRl5RJ9rAMgfRXHqGG8RM2Dlf95JwTXzerY6igUq7FVgFjnPbexVt3 vKKyjdy2gBuOaXqaYJEZSfuKCNN/WbOF8e7ny4fLMFilbhpzoqkSHiR+nAHLkYct OnOKMPK1SwmvkNMn3aTEJHhxGh1RlWbMAAQ+QLI2D7zCzQ7Uh3F+Kw0pd2gBYd8W +DKTn1Tprugdykirr6u0p66yK5f1T9O+LEaJa8FjtLF66siBdGRaNYMExNi21lJk i5dD3ViVBIVKi9ZaTsK9Sxa3dOX1aE5Zd5A9cPsBIZ12spYgemfj6DjOw6lk7jkG 9QIDAQAB -----END PUBLIC KEY-----

新建一個 public.cert 檔案,將上面的 公鑰資訊 複製到 public.cert 檔案中並儲存。並將檔案放到 user-service資源服務src/main/resources 目錄下。至此 auth-service 搭建完畢。

  • pom.xml 中配置 jks 檔案字尾過濾器

maven 在專案編譯時,可能會將 jks 檔案 編譯,導致 jks 檔案 亂碼,最後不可用。需要在 pom.xml 檔案中新增以下內容:

<!-- 防止jks檔案被maven編譯導致不可用 -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-resources-plugin</artifactId>
    <configuration>
        <nonFilteredFileExtensions>
            <nonFilteredFileExtension>cert</nonFilteredFileExtension>
            <nonFilteredFileExtension>jks</nonFilteredFileExtension>
        </nonFilteredFileExtensions>
    </configuration>
</plugin>
複製程式碼
  • 最後在啟動類上配置 @EnableEurekaClient 註解開啟服務註冊功能。
@EnableEurekaClient
@SpringBootApplication
public class AuthServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(AuthServiceApplication.class, args);
    }
}
複製程式碼

6. 構建user-service資源服務

  • 新建一個 user-service 專案模組,完整的 pom.xml 檔案配置如下:
<?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>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.3.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>io.github.ostenant.springcloud</groupId>
    <artifactId>user-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <name>user-service</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Dalston.SR1</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-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-hystrix</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-feign</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <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>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
複製程式碼
  • 修改 user-service 的配置檔案 application.yml,配置 應用名稱user-service埠號9090。另外,需要配置 feign.hystrix.enabletrue,即開啟 FeignHystrix 功能。完整的配置程式碼如下:
server:
  port: 9090
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
spring:
  application:
    name: user-service
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/spring-cloud-auth?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
    username: root
    password: 123456
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
feign:
  hystrix:
    enabled: true
複製程式碼
  • 配置 資源服務

注入 JwtTokenStore 型別的 Bean,同時初始化 JWT 轉換器 JwtAccessTokenConverter,設定用於解密 JWT公鑰

@Configuration
public class JwtConfig {
    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    @Bean
    @Qualifier("tokenStore")
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter);
    }

    @Bean
    public JwtAccessTokenConverter jwtTokenEnhancer() {
        // 用作JWT轉換器
        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;
    }
}
複製程式碼

配置 資源服務 的認證管理,除了 註冊登入 的介面之外,其他的介面都需要 認證

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter{
    @Autowired
    private 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);
    }
}
複製程式碼

新建一個配置類 GlobalMethodSecurityConfig,通過 @EnableGlobalMethodSecurity 註解開啟 方法級別安全驗證

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class GlobalMethodSecurityConfig {
}
複製程式碼
  • 實現使用者註冊介面

拷貝 auth-service 模組的 UserRoleUserRepository 三個類到本模組。在 Service 層的 UserService 編寫一個 插入使用者 的方法,程式碼如下:

@Service
public class UserServiceDetail {
    @Autowired
    private UserRepository userRepository;

    public User insertUser(String username,String  password){
        User user=new User();
        user.setUsername(username);
        user.setPassword(BPwdEncoderUtil.BCryptPassword(password));
        return userRepository.save(user);
    }
}
複製程式碼

配置用於使用者密碼 加密 的工具類 BPwdEncoderUtil

public class BPwdEncoderUtil {
    private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

    public static String BCryptPassword(String password){
        return encoder.encode(password);
    }

    public static boolean matches(CharSequence rawPassword, String encodedPassword){
        return encoder.matches(rawPassword,encodedPassword);
    }
}
複製程式碼

實現一個 使用者註冊API 介面 /user/register,程式碼如下:

@RestController
@RequestMapping("/user")
public class UserController {
    @Autowired
    UserServiceDetail userServiceDetail;

    @PostMapping("/register")
    public User postUser(@RequestParam("username") String username,
                         @RequestParam("password") String password){
       return userServiceDetail.insertUser(username, password);
    }
}
複製程式碼
  • 實現使用者登入介面

Service 層的 UserServiceDetail 中新增一個 login() 方法,程式碼如下:

@Service
public class UserServiceDetail {

    @Autowired
    private AuthServiceClient client;

    public UserLoginDTO login(String username, String password) {
        // 查詢資料庫
        User user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UserLoginException("error username");
        }

        if(!BPwdEncoderUtil.matches(password,user.getPassword())){
            throw new UserLoginException("error password");
        }

        // 從auth-service獲取JWT
        JWT jwt = client.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 Client,通過向 auth-service 服務介面 /oauth/token 遠端呼叫獲取 JWT。在請求 /oauth/tokenAPI 介面中,需要在 請求頭 傳入 Authorization 資訊,認證型別 ( grant_type )、使用者名稱 ( username ) 和 密碼 ( password ),程式碼如下:

@FeignClient(value = "auth-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);
}
複製程式碼

其中,AuthServiceHystrixAuthServiceClient熔斷器,程式碼如下:

@Component
public class AuthServiceHystrix implements AuthServiceClient {
    private static final Logger LOGGER = LoggerFactory.getLogger(AuthServiceHystrix.class);

    @Override
    public JWT getToken(String authorization, String type, String username, String password) {
        LOGGER.warn("Fallback of getToken is executed")
        return null;
    }
}
複製程式碼

JWT 包含了 access_tokentoken_typerefresh_token 等資訊,程式碼如下:

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;
    }
}
複製程式碼

UserLoginDTO 包含了一個 User 和一個 JWT 成員屬性,用於返回資料的實體:

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;
    }
}
複製程式碼

登入異常類 UserLoginException

public class UserLoginException extends RuntimeException {
    public UserLoginException(String message) {
        super(message);
    }
}
複製程式碼

全域性異常處理 切面類 ExceptionHandle

@ControllerAdvice
@ResponseBody
public class ExceptionHandler {
    @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);
}
複製程式碼
  • 為了測試 使用者許可權,再新增一個 /foo 介面,該介面需要 ROLE_ADMIN 許可權才能正常訪問。
@RequestMapping(value = "/foo", method = RequestMethod.GET)
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public String getFoo() {
    return "i'm foo, " + UUID.randomUUID().toString();
}
複製程式碼
  • 最後在應用的啟動類上使用註解 @EnableFeignClients 開啟 Feign 的功能即可。
@SpringBootApplication
@EnableFeignClients
@EnableEurekaClient
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}
複製程式碼

依次啟動 eureka-serviceauth-serviceuser-service 三個服務。

7. 使用Postman測試

  • 註冊一個使用者,返回註冊成功資訊

Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

  • 使用使用者名稱密碼登入獲取 JWT

Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

  • 複製上面的 access_tokenheader 頭部,請求需要 使用者許可權/user/foo 介面
"Authorization": "Bearer {access_token}"
複製程式碼

Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

因為沒有許可權,訪問被拒絕。在資料庫手動新增 ROLE_ADMIN 許可權,並與該使用者關聯。重新登入並獲取 JWT,再次請求 /user/foo 介面。

Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuth

總結

在本案例中,使用者通過 登入介面 來獲取 授權服務 加密後的 JWT。使用者成功獲取 JWT 後,在以後每次訪問 資源服務 的請求中,都需要攜帶上 JWT資源服務 通過 公鑰解密 JWT解密成功 後可以獲取 使用者資訊許可權資訊,從而判斷該 JWT 所對應的 使用者 是誰,具有什麼 許可權

  • 優點

獲取一次 Token,多次使用,資源服務 不再每次訪問 授權服務Token 所對應的 使用者資訊 和使用者的 許可權資訊

  • 缺點

一旦 使用者資訊 或者 許可權資訊 發生了改變,Token 中儲存的相關資訊並 沒有改變,需要 重新登入 獲取新的 Token。就算重新獲取了 Token,如果原來的 Token 沒有過期,仍然是可以使用的。一種改進方式是在登入成功後,將獲取的 Token 快取閘道器上。如果使用者的 許可權更改,將 閘道器 上快取的 Token 刪除。當請求經過 閘道器,判斷請求的 Token快取 中是否存在,如果快取中不存在該 Token,則提示使用者 重新登入

參考

  • 方誌朋《深入理解Spring Cloud與微服務構建》

歡迎關注技術公眾號: 零壹技術棧

零壹技術棧

本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。

相關文章