Spring Cloud 學習 (九) Spring Security, OAuth2


Spring Security

Spring Security 是 Spring Resource 社區的一個安全組件。在安全方面,有兩個主要的領域,一是“認證”,即你是誰;二是“授權”,即你擁有什么權限,Spring Security 的主要目標就是在這兩個領域

Spring OAuth2

OAuth2 是一個標准的授權協議,允許不同的客戶端通過認證和授權的形式來訪問被其保護起來的資源

OAuth2 協議在 Spring Resource 中的實現為 Spring OAuth2,Spring OAuth2 分為:OAuth2 Provider 和 OAuth2 Client

OAuth2 Provider

OAuth2 Provider 負責公開被 OAuth2 保護起來的資源

OAuth2 Provider 需要配置代表用戶的 OAuth2 客戶端信息,被用戶允許的客戶端就可以訪問被 OAuth2 保護的資源。OAuth2 Provider 通過管理和驗證 OAuth2 令牌來控制客戶端是否有權限訪問被其保護的資源

另外,OAuth2 Provider 還必須為用戶提供認證 API 接口。根據認證 API 接口,用戶提供賬號和密碼等信息,來確認客戶端是否可以被 OAuth2 Provider 授權。這樣做的好處就是第三方客戶端不需要獲取用戶的賬號和密碼,通過授權的方式就可以訪問被 OAuth2 保護起來的資源

OAuth2 Provider 的角色被分為 Authorization Service (授權服務) 和 Resource Service (資源服務),通常它們不在同一個服務中,可能一個 Authorization Service 對應多個 Resource Service

Spring OAuth2 需配合 Spring Security 一起使用,所有的請求由 Spring MVC 控制器處理,並經過一系列的 Spring Security 過濾器

在 Spring Security 過濾器鏈中有以下兩個節點,這兩個節點是向 Authorization Service 獲取驗證和授權的:

  1. 授權節點:默認為 /oauth/authorize
  2. 獲取 Token 節點:默認為 /oauth/token

OAuth2 Client

OAuth2 Client (客戶端) 用於訪問被 OAuth2 保護起來的資源

新建 spring-security-oauth2-server

pom

<parent>
    <artifactId>spring-cloud-parent</artifactId>
    <groupId>com.karonda</groupId>
    <version>1.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>spring-security-oauth2-server</artifactId>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-rest</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</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>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

application.yml

server:
  port: 8081

  servlet:
    context-path: /uaa # User Account and Authentication

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8001/eureka/

spring:
  application:
    name: oauth2-server

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/spring-security-auth2?useSSL=false
    username: root
    password: root

    hikari:
      maximum-pool-size: 20
      minimum-idle: 5

  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    show-sql: true
    hibernate:
      ddl-auto: update

  main:
    allow-bean-definition-overriding: true

數據表

創建用戶和角色及中間表:

DROP TABLE IF EXISTS role; 
CREATE TABLE role
(
    id bigint(20) NOT NULL AUTO_INCREMENT,
    name varchar(255) NOT NULL,
    PRIMARY KEY (id)
);

DROP TABLE IF EXISTS user; 
CREATE TABLE user 
(
    id bigint(20) NOT NULL AUTO_INCREMENT,
    password varchar(255) DEFAULT NULL, 
    username varchar(255) NOT NULL ,
    PRIMARY KEY (id ), 
    UNIQUE KEY (username)
);

DROP TABLE IF EXISTS user_role; 
CREATE TABLE user_role (
    user_id bigint(20) NOT NULL,
    role_id bigint(20) NOT NULL, 
    KEY (user_id), 
    KEY (role_id), 
    FOREIGN KEY (user_id) REFERENCES user (id),
    FOREIGN KEY (role_id) REFERENCES role (id)
);
`

OAuth2 Client 信息可以存儲在數據庫中,Spring OAuth2 已經設計好了數據表,且不可變,創建數據表的腳本:schema.sql (如果使用 MySQL 需要將 LONGVARBINARY 替換為 BLOB)

初始化數據

INSERT INTO role (name) VALUES ('ROLE_USER');
INSERT INTO role (name) VALUES ('ROLE_ADMIN');

INSERT INTO user (username, password) VALUES ('test', '123');

INSERT INTO user_role (user_id, role_id) VALUES (1, 1);

Entity & Dao & Service

@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;
    }
}

Role 實現了 GrantedAuthority 接口

@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 User(){

    }

    public Long getId() {
        return id;
    }

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

    @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 Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

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

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

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

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

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

}

User 實現了 UserDetails 接口,該接口是 Spring Security 認證信息的核心接口

public interface UserDao extends JpaRepository<User, Long> {
    User findByUsername(String username);
}
@Service
public class UserServiceDetail implements UserDetailsService {

    @Autowired
    private UserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userDao.findByUsername(username);
    }
}

UserServiceDetail 實現了 UserDetailsService 接口

啟動類

@EnableEurekaClient
@SpringBootApplication
public class Oauth2ServerApp {

    public static void main(String[] args){
        SpringApplication.run(Oauth2ServerApp.class, args);
    }

}

Spring Security 配置

@Configuration
@EnableWebSecurity // 開啟 Spring Security
@EnableGlobalMethodSecurity(prePostEnabled = true) // 開啟方法級別上的保護
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserServiceDetail userServiceDetail;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated() // 所有請求都需要安全驗證
                .and()
                .csrf().disable();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userServiceDetail).passwordEncoder(passwordEncoder());
    }

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

Spring Security 5 使用 Spring Security 4 的配置會報 There is no PasswordEncoder mapped for the id “null” 異常,解決方法是使用 NoOpPasswordEncoder (臨時解決方案,非最優方案)

—- 2020-04-24 更新開始 —-

PasswordEncoder 可以使用自定義 Encoder:

@Bean
public PasswordEncoder passwordEncoder() {
    return new MyPasswordEncoder();
}

MyPasswordEncoder 代碼:

public class MyPasswordEncoder implements PasswordEncoder {

    @Override
    public String encode(CharSequence charSequence) {
        return PasswordUtil.getencryptPassword((String)charSequence);
    }

    @Override
    public boolean matches(CharSequence charSequence, String s) {
        boolean result = PasswordUtil.matches(charSequence, s);
        return result;
    }
}

PasswordUtil 代碼:

public class PasswordUtil {

    private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

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

    public static boolean matches(CharSequence rawPassword, String encodedPassword){
        return encoder.matches(rawPassword, encodedPassword);
    }
}

—- 2020-04-24 更新結束 —-

Authorization Server 配置

@Configuration
@EnableAuthorizationServer // 開啟授權服務
@EnableResourceServer // 需要對外暴露獲取和驗證 Token 的接口,所以也是一個資源服務
public class OAuth2Config extends AuthorizationServerConfigurerAdapter{

    @Autowired
    private DataSource dataSource;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserServiceDetail userServiceDetail;

    @Override
    // 配置客戶端信息
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory() // 將客戶端的信息存儲在內存中
                .withClient("browser") // 客戶端 id, 需唯一
                .authorizedGrantTypes("refresh_token", "password") // 認證類型為 refresh_token, password
                .scopes("ui") // 客戶端域
                .and()
                .withClient("eureka-client") // 另一個客戶端
                .secret("123456")  // 客戶端密碼
                .authorizedGrantTypes("client_credentials", "refresh_token", "password")
                .scopes("server");
    }

    @Override
    // 配置授權 token 的節點和 token 服務
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore()) // token 的存儲方式
                .authenticationManager(authenticationManager) // 開啟密碼驗證,來源於 WebSecurityConfigurerAdapter
                .userDetailsService(userServiceDetail); // 讀取驗證用戶的信息
    }

    @Override
    // 配置 token 節點的安全策略
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.tokenKeyAccess("permitAll()") // 獲取 token 的策略
                .checkTokenAccess("isAuthenticated()");
    }

    @Bean
    public TokenStore tokenStore() {

//        return new InMemoryTokenStore();

        return new JdbcTokenStore(dataSource);
    }
}

RemoteTokenServices 接口

@RestController
@RequestMapping("/users")
public class UserController {

    @RequestMapping(value = "/current", method = RequestMethod.GET)
    public Principal getUser(Principal principal){
        return principal;
    }
}

本文采用 RemoteTokenServices 這種方式對 Token 進行驗證,如果其他資源服務需要驗證 Token 則需要遠程調用授權服務暴露的驗證 Token 的 API 接口

測試

  1. 啟動 eureka-server
  2. 啟動 oauth2-server

使用 Postman 測試:

     
- POST http://localhost:8081/uaa/oauth/token
Headers    
- Authorization Basic ZXVyZWthLWNsaWVudDoxMjM0NTY=
Body    
- username test
- password 123
- grant_type password

其中 Authorization 的值為 Basic clientId:secret (本文中為 eureka-client:123456) Base64 加密后的值

返回結果:

{
    "access_token": "5dc978ab-8c7e-4286-92f5-5655b8d15c98",
    "token_type": "bearer",
    "refresh_token": "7ef02b1c-6e8a-485f-adc9-18a48c2ae410",
    "expires_in": 43199,
    "scope": "server"
}

eureka-client

添加依賴

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</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>

application.xml 添加

security:
  oauth2:
    resource:
      user-info-uri: http://localhost:8081/uaa/users/current
    client:
      client-id: eureka-client
      client-secret: 123456
      access-token-uri: http://localhost:8081/uaa/oauth/token
      grant-type: client_credentials, password
      scope: server

數據庫配置同 oauth2-server 未列出

配置 Resource Server

@Configuration
@EnableResourceServer // 開啟資源服務
@EnableGlobalMethodSecurity(prePostEnabled = true) // 開啟方法級別上的保護
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
                .antMatchers("/user/register").permitAll()
                .anyRequest().authenticated();
    }
}

配置 OAuth2 Client

@EnableOAuth2Client // 開啟 OAuth2 Client
@EnableConfigurationProperties
@Configuration
public class OAuth2ClientConfig {

    @Bean
    @ConfigurationProperties(prefix = "security.oauth2.client")
    // 配置受保護的資源信息
    public ClientCredentialsResourceDetails clientCredentialsResourceDetails(){
        return new ClientCredentialsResourceDetails();
    }

    @Bean
    // 過濾器,存儲當前請求和上下文
    public RequestInterceptor oAuth2FeignRequestInterceptor(){
        return new OAuth2FeignRequestInterceptor(new DefaultOAuth2ClientContext(), clientCredentialsResourceDetails());
    }

    @Bean
    public OAuth2RestTemplate clientCredentialsRestTemplate (){
        return new OAuth2RestTemplate(clientCredentialsResourceDetails());
    }
}

Entity & Dao & Service

與 oauth2-server 類似,具體見代碼

Controller

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @RequestMapping(value = "/register", method = RequestMethod.POST)
    public User createUser(@RequestParam("username") String username
            , @RequestParam("password") String password){
        return userService.create(username, password);
    }
}
@RestController
public class HiController {

    @Value("${server.port}")
    int port;

    @Value("${version}")
    String version;

    @GetMapping("/hi")
    public String home(@RequestParam String name){
        return "Hello " + name + ", from port: " + port + ", version: " + version;
    }

    @PreAuthorize("hasAuthority('ROLE_ADMIN')") // 需要權限
    @RequestMapping("/hello")
    public String hello(){
        return "hello!";
    }
}

測試

  1. 啟動 eureka-server
  2. 啟動 oauth2-server
  3. 啟動 config-server
  4. 啟動 eureka-client

使用 Postman 測試:

注冊用戶

     
- POST localhost:8011/user/register
Body    
- username admin
- password 123

返回結果:

{
    "id": 2,
    "username": "admin",
    "password": "123",
    "authorities": null,
    "enabled": true,
    "accountNonExpired": true,
    "credentialsNonExpired": true,
    "accountNonLocked": true
}

請求 token

     
- POST http://localhost:8081/uaa/oauth/token
Headers    
- Authorization Basic ZXVyZWthLWNsaWVudDoxMjM0NTY=
Body    
- username admin
- password 123
- grant_type password
{
    "access_token": "dc4959fb-9ced-430e-9a78-5e9609c3baac",
    "token_type": "bearer",
    "refresh_token": "06cdcf55-fe6a-4367-94e1-051c2da86e37",
    "expires_in": 43199,
    "scope": "server"
}

訪問不需要權限的接口

     
- GET localhost:8011/hi?name=Victor
Headers    
- Authorization Bearer dc4959fb-9ced-430e-9a78-5e9609c3baac
Hello Victor, from port: 8011, version: 1.0.2

訪問需要權限的接口

     
- GET localhost:8011/hello
Headers    
- Authorization Bearer dc4959fb-9ced-430e-9a78-5e9609c3baac
{
    "error": "access_denied",
    "error_description": "不允許訪問"
}

手動授權:

INSERT INTO user_role (user_id, role_id) VALUES (2, 2);

重新獲取 token 后再次訪問接口

hello!

完整代碼:GitHub

本人 C# 轉 Java 的 newbie, 如有錯誤或不足歡迎指正,謝謝


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM