SpringBoot實現OAuth2認證服務器


一、最簡單認證服務器

1. pom依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.0.RELEASE</version>
</dependency>

2. 配置application.yml

security:
  oauth2:
    client:
      client-id: clientId
      client-secret: clientSecret
      scope: scope1, scope2, scope3, scope4
      registered-redirect-uri: http://www.baidu.com
spring: security: user: name: admin password: admin

3. 開啟@EnableAuthorizationServer,同時開啟SpringSecurity用戶登錄認證

@SpringBootApplication
@EnableAuthorizationServer
public class SpringBootTestApplication {

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

    @Bean
    public WebSecurityConfigurerAdapter webSecurityConfigurerAdapter() {
        return new WebSecurityConfigurerAdapter() {
            @Override
            public void configure(HttpSecurity httpSecurity) throws Exception {
                httpSecurity.formLogin().and().csrf().disable(); 
       }
     };
   }
}

4. 測試

(1)密碼模式和客戶端模式直接通過單元測試就可以完成

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringBootTestApplicationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void token_password() {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "password");
        params.add("username", "admin");
        params.add("password", "admin");
        params.add("scope", "scope1 scope2");
        String response = restTemplate.withBasicAuth("clientId", "clientSecret").
                postForObject("/oauth/token", params, String.class);
        System.out.println(response);
    }

    @Test
    public void token_client() {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "client_credentials");
        String response = restTemplate.withBasicAuth("clientId", "clientSecret").
                postForObject("/oauth/token", params, String.class);
        System.out.println(response);
    }
    
}

(2)授權碼驗證模式

  • 訪問 http://127.0.0.1:8080/oauth/authorize?client_id=clientId&response_type=code,跳轉到SpringSecurity默認的登錄頁面:
  • 輸入用戶名/密碼:admin/admin,點擊登錄后跳轉到確認授權頁面:

     

  • 至少選中一個,然后點擊Authorize按鈕,跳轉到 https://www.baidu.com/?code=tg0GDq,這樣我們就拿到了授權碼。

  • 通過授權碼申請token: 

    @Test
    public void token_code() {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("code", "tg0GDq");
        String response = restTemplate.withBasicAuth("clientId", "clientSecret").postForObject("/oauth/token", params, String.class);
        System.out.println(response);
    }

(3)刷新token

@Test
    public void token_refresh() {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "refresh_token");
        params.add("refresh_token", "fb00358a-44e2-4679-9129-1b96f52d8d5d");
        String response = restTemplate.withBasicAuth("clientId", "clientSecret").
                postForObject("/oauth/token", params, String.class);
        System.out.println(response);
    }

刷新token功能報錯,// todo 2018-11-08 此處留坑

二、比較復雜的認證服務器

上面我們搭建的認證服務器存在以下弊端:

  1. clientId和clientSecret是寫死在配置文件里的。
  2. 用戶信息寫死在配置文件里。
  3. 通過clientId和clientSecret獲取的code和token都存在內存中。第一:如果服務器宕機code和token會丟失;第二:不支持多點部署。

針對以上問題,我們要做的就是

  1. 將clientId和clientSecret等信息存儲在數據庫中。
  2. 將用戶信息存儲在數據庫中。
  3. 將code和token存儲在redis中。

接下來我們一步一步實現:

1. 創建測試用表及數據

drop table if exists test.oauth2_client;
create table test.oauth2_client (
  id int auto_increment primary key,
  clientId varchar(50),
  clientSecret varchar(50),
  redirectUrl varchar(2000),
  grantType varchar(100),
  scope varchar(100)
);

insert into test.oauth2_client(clientId, clientSecret, redirectUrl, grantType, scope)
values ('clientId','clientSecret','http://www.baidu.com,http://www.csdn.net', 'authorization_code,client_credentials,password,implicit', 'scope1,scope2');


drop table if exists test.oauth2_user;
create table test.oauth2_user (
  id int auto_increment primary key,
  username varchar(50),
  password varchar(50)
);

insert into test.oauth2_user (username, password)
values ('admin','admin');

insert into test.oauth2_user (username, password)
values ('guest','guest');
創建測試用表及數據
  • 表oauth2_client:存儲clientId、clientSecret及其他信息。本例只創建了一個client。
  • 表oauth2_user:用戶信息。本例創建了兩個用戶:admin/admin、guest/guest。

2. Dao和Service

Dao和Service就不用廢話了,肯定要有的

public class Oauth2Client {

    private int id;
    private String clientId;
    private String clientSecret;
    private String redirectUrl;
    private String grantType;
    private String scope;

    public int getId() {
        return id;
    }

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

    public String getClientId() {
        return clientId;
    }

    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    public String getClientSecret() {
        return clientSecret;
    }

    public void setClientSecret(String clientSecret) {
        this.clientSecret = clientSecret;
    }

    public String getRedirectUrl() {
        return redirectUrl;
    }

    public void setRedirectUrl(String redirectUrl) {
        this.redirectUrl = redirectUrl;
    }

    public String getGrantType() {
        return grantType;
    }

    public void setGrantType(String grantType) {
        this.grantType = grantType;
    }

    public String getScope() {
        return scope;
    }

    public void setScope(String scope) {
        this.scope = scope;
    }
}
Oauth2Client
public class Oauth2User {

    private int id;
    private String username;
    private String password;

    public int getId() {
        return id;
    }

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

    public String getUsername() {
        return username;
    }

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

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}
Oauth2User
@Repository
public class Oauth2Dao {

    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public Oauth2Dao(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<Oauth2Client> getOauth2ClientByClientId(String clientId) {
        String sql = "select * from oauth2_client where clientId = ?";
        return jdbcTemplate.query(sql, new String[]{clientId}, new BeanPropertyRowMapper<>(Oauth2Client.class));
    }

    public List<Oauth2User> getOauth2UserByUsername(String username) {
        String sql = "select * from oauth2_user where username = ?";
        return jdbcTemplate.query(sql, new String[]{username}, new BeanPropertyRowMapper<>(Oauth2User.class));
    }
    
}
Oauth2Dao
@Service
public class Oauth2Service {

    private final Oauth2Dao oauth2Dao;

    @Autowired
    public Oauth2Service(Oauth2Dao oauth2Dao) {
        this.oauth2Dao = oauth2Dao;
    }

    public List<Oauth2Client> getOauth2ClientByClientId(String clientId) {
        return oauth2Dao.getOauth2ClientByClientId(clientId);
    }

    public List<Oauth2User> getOauth2UserByUsername(String username) {
        return oauth2Dao.getOauth2UserByUsername(username);
    }
}
Oauth2Service

3. 增加pom依賴

因為要使用到數據庫以及redis,所以我們需要增加如下依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

4. 修改啟動主類,增加bean注冊

(1)注冊一個PasswordEncoder用於密碼加密:

這樣做的目的是:在我們的應用中,可能都多個地方需要我們對用戶的明文密碼進行加密。在這里我們統一注冊一個PasswordEncoder,以保證加密算法的一致性。

@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

(2)注冊一個UserDetailsService用於用戶身份認證

@Bean
public UserDetailsService userDetailsService(Oauth2Service oauth2Service, PasswordEncoder passwordEncoder) {
    return username -> {
        List<Oauth2User> users = oauth2Service.getOauth2UserByUsername(username);
        if (users == null || users.size() == 0) {
            throw new UsernameNotFoundException("username無效");
        }
        Oauth2User user = users.get(0);
        String passwordAfterEncoder = passwordEncoder.encode(user.getPassword());
        return User.withUsername(username).password(passwordAfterEncoder).roles("").build();
    };
}

標紅這句代碼大家忽略吧,常理來講數據庫中存儲的密碼應該就是密文所以這句代碼是不需要的,我比較懶數據庫直接存儲明文密碼所以這里需要加密一下。

(3)注冊一個ClientDetailsService用戶clientId和clientSecret驗證

@Bean
public ClientDetailsService clientDetailsService(Oauth2Service oauth2Service, PasswordEncoder passwordEncoder) {
    return clientId -> {
        List<Oauth2Client> clients1 = oauth2Service.getOauth2ClientByClientId(clientId);
        if (clients1 == null || clients1.size() == 0) {
            throw new ClientRegistrationException("clientId無效");
        }
        Oauth2Client client = clients1.get(0);
        String clientSecretAfterEncoder = passwordEncoder.encode(client.getClientSecret());
        BaseClientDetails clientDetails = new BaseClientDetails();
        clientDetails.setClientId(client.getClientId());
        clientDetails.setClientSecret(clientSecretAfterEncoder);
        clientDetails.setRegisteredRedirectUri(new HashSet<>(Arrays.asList(client.getRedirectUrl().split(","))));
        clientDetails.setAuthorizedGrantTypes(Arrays.asList(client.getGrantType().split(",")));
        clientDetails.setScope(Arrays.asList(client.getScope().split(",")));
        return clientDetails;
    };
}

標紅代碼忽略,理由同上。

關於BaseClientDetails的屬性,這里要啰嗦幾句:它繼承於接口ClientDetails,該接口包含如下屬性:

  • getClientId:clientId,唯一標識,不能為空
  • getClientSecret:clientSecret,密碼
  • isSecretRequired:是否需要驗證密碼
  • getScope:可申請的授權范圍
  • isScoped:是否需要驗證授權范圍
  • getResourceIds:允許訪問的資源id,這個涉及到資源服務器
  • getAuthorizedGrantTypes:可使用的Oauth2授權模式,不能為空
  • getRegisteredRedirectUri:回調地址,用戶在authorization_code模式下接收授權碼code
  • getAuthorities:授權,這個完全等同於SpringSecurity本身的授權
  • getAccessTokenValiditySeconds:access_token過期時間,單位秒。null等同於不過期
  • getRefreshTokenValiditySeconds:refresh_token過期時間,單位秒。null等同於getAccessTokenValiditySeconds,0或者無效數字等同於不過期
  • isAutoApprove:判斷是否獲得用戶授權scope

 (4)注冊一個TokenStore以保存token信息

@Bean
public TokenStore tokenStore(RedisConnectionFactory redisConnectionFactory) {
    return new RedisTokenStore(redisConnectionFactory);
}

(5)注冊一個AuthorizationCodeServices以保存authorization_code的授權碼code

生成一個RandomValueAuthorizationCodeServices的bean,而不是直接生成AuthorizationCodeServices的bean。RandomValueAuthorizationCodeServices可以幫我們完成code的生成過程。如果你想按照自己的規則生成授權碼code請直接生成AuthorizationCodeServices的bean。

@Bean
public AuthorizationCodeServices authorizationCodeServices(RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<String, OAuth2Authentication> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    redisTemplate.afterPropertiesSet();

    return new RandomValueAuthorizationCodeServices() {

        @Override
        protected void store(String code, OAuth2Authentication authentication) {
            redisTemplate.boundValueOps(code).set(authentication, 10, TimeUnit.MINUTES);
        }

        @Override
        protected OAuth2Authentication remove(String code) {
            OAuth2Authentication authentication = redisTemplate.boundValueOps(code).get();
            redisTemplate.delete(code);
            return authentication;
        }
    };
}

(6)注冊一個AuthenticationManager用來password模式下用戶身份認證

直接使用上面注冊的UserDetailsService來完成用戶身份認證。

@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userDetailsService);
    provider.setPasswordEncoder(passwordEncoder);
    return new ProviderManager(Collections.singletonList(provider));
}

(7)配置認證服務器

上面注冊了這么多bean,到了他們發揮作用的時候了

@Bean
public AuthorizationServerConfigurer authorizationServerConfigurer(UserDetailsService userDetailsService, ClientDetailsService clientDetailsService, 
                  TokenStore tokenStore, AuthorizationCodeServices authorizationCodeServices, AuthenticationManager authenticationManager) {
return new AuthorizationServerConfigurer() { @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.withClientDetails(clientDetailsService); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.userDetailsService(userDetailsService); endpoints.tokenStore(tokenStore); endpoints.authorizationCodeServices(authorizationCodeServices); endpoints.authenticationManager(authenticationManager); } }; }

5. 修改配置文件,配置數據庫及redis連接

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://192.168.2.12:3306/test?characterEncoding=utf8
    username: root
    password: onceas

  redis:
    host: 192.168.2.12
    port: 6379
    password: 123456

6.測試

(1)密碼模式和客戶端模式同上

(2)授權碼驗證模式

  • 訪問 http://127.0.0.1:8080/oauth/authorize?client_id=clientId&response_type=code&scope=scope1 scope2&redirect_uri=http://www.baidu.com,跳轉到SpringSecurity默認的登錄頁面:
  • 輸入用戶名/密碼:admin/admin,點擊登錄后跳轉到確認授權頁面:

     

  • 至少選中一個,然后點擊Authorize按鈕,跳轉到 https://www.baidu.com/?code=tg0GDq,這樣我們就拿到了授權碼。

  • 通過授權碼申請token: 

    @Test
    public void token_code() { MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add("grant_type", "authorization_code"); params.add("code", "tg0GDq"); String response = restTemplate.withBasicAuth("clientId", "clientSecret").postForObject("/oauth/token", params, String.class); System.out.println(response); }

(3)刷新token

申請的所有token中都沒有返回refresh_token,// todo 2018-11-08 此處留坑

三、自定義頁面

 

1. 自定義用戶登錄頁面

用戶登錄頁面就是SpringSecurity的默認登錄頁面,所以按照SpringSecurity的規則更改即可,可參照https://www.cnblogs.com/LOVE0612/p/9897647.html里面的相關內容

2. 自定義用戶授權頁面

用戶授權頁面是/oauth/authorize轉發給/oauth/confirm_access然后才呈現最終頁面給用戶的。所以想要自定義用戶授權頁面,用戶點擊Authorize按鈕時會通過form表單發送請求:

Request URL: http://127.0.0.1:8080/oauth/authorize
Request Method: POST

FormData
user_oauth_approval: true
scope.scope1: true
scope.scope2: true

所以我們要自定義用戶授權頁面,我們只要重新定義一個mapping即可並按照上述要求完成post請求即可。

(1)增加pom依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

(2)Controller

@Controller
public class Oauth2Controller {

    @GetMapping("oauth/confirm_access")
    public String authorizeGet() {
        return "oauth/confirm_access";
    } 
}

(3)創建/resources/templates/oauth/confirm_access.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>my authorize page</title>
</head>
<body>
<form action="/oauth/authorize" method="post">
    <input type="hidden" name="user_oauth_approval" value="true">
    <div id="scope"></div>
    <input type="submit" value="授權">
</form>
<script>
    function getQueryString(name) {
        var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
        var r = window.location.search.substr(1).match(reg);
        if (r != null) return unescape(r[2]);
        return null;
    }
</script>
<script>
    var scope = getQueryString("scope");

    var scopeList = scope.split(" ");
    var html = "";
    for (var i = 0; i < scopeList.length; i++) {
        html += scopeList[i] + ":<input type='checkbox' name='scope." + scopeList[i] + "' value='true'/><br />";
    }
    document.getElementById("scope").innerHTML = html;
</script>
</body>
</html>

3. 自定義錯誤頁面

與上面同理,重新定義一個mapping對應uri:/oauth/error,可通過 Object error = request.getAttribute("error"); 獲取錯誤信息,具體html頁面內容就不再贅述了。

四、支持Restfull風格

如果考慮前后分離呢?那么流程應該是:

  1. 用戶訪問第三方client網站
  2. 第三方網站將用戶導向我們的某個前端頁面地址並攜帶參數client_id、scope、redirect_uri
  3. 該前端頁面通過ajax請求后台接口/oauth/authorize?client_id={client_id}&response_type=code&scope={scope}&redirect_uri={redirect_uri}
  4. 后端接到請求后SpringSecurity首先會校驗參數合法性,不合法則轉發到/oauth/error,/oauth/error返回jons結果告知前端參數不合法。如果參數合法則再判斷當前是否已有用戶通過認證:有,則會將請求轉發到/oauth/confirm_access,/oauth/confirm_access方法返回json結果告知前端需要用戶授權;如果沒有則會將請求轉發到/login(get請求),/login方法也返回json結果告知前端需要用戶登錄。
  5. 前端頁面根據返回結果判斷,如果需要登錄則跳轉到登錄頁面,如果需要用戶授權則跳轉到用戶授權頁面。
  6. 如果跳轉到用戶登錄頁面,用戶輸入用戶名密碼點擊登錄按鈕,前端通過ajax請求后台接口/login(post請求),后端接到請求后SpringSecurity判斷用戶認證是否通過:如果通過則轉發請求到and().formLogin().successForwardUrl()所設定的uri,該uri返回json結果告知用戶登錄成功。如果未通過則轉發請求到and().formLogin().failureForwardUrl("/login/error")所設定的uri,該uri返回json結果告知用戶登錄失敗
  7. 前端用戶登錄頁面拿到后端返回的登錄結果,如果登錄失敗則繼續等待用戶填寫用戶名密碼重新登錄,如果登錄成功則跳轉到用戶授權頁面。
  8. 用戶進行授權勾選並點擊確認授權后,前端通過表單post到后台接口/oauth/authorize
  9. 后端接到請求后處理並重定向會第三方client回調地址


免責聲明!

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



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