搭建一個oauth2服務器,包括認證、授權和資源服務器
項目地址:https://github.com/zheyday/OAuth2
本文分爲兩個部分
- 第一部分比較簡單,將客戶端信息和用戶信息固定在程序裏,令牌存儲在內存中
- 第二部分從數據庫讀取用戶信息,使用jwt生成令牌
一、簡化版
使用Spring Initializr新建項目,勾選如下三個選項
pom.xml
<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-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
配置Spring Security
新建類WebSecurityConfig 繼承 WebSecurityConfigurerAdapter,並添加@Configuration @EnableWebSecurity註解,重寫三個方法,代碼如下,詳細講解在代碼下面
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceDetail userServiceDetail;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userServiceDetail).passwordEncoder(passwordEncoder());
//內存存儲
// auth
// .inMemoryAuthentication()
// .passwordEncoder(passwordEncoder())
// .withUser("user")
// .password(passwordEncoder().encode("user"))
// .roles("USER");
}
/**
* 配置了默認表單登陸以及禁用了 csrf 功能,並開啓了httpBasic 認證
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http // 配置登陸頁/login並允許訪問
.formLogin().permitAll()
// 登出頁
.and().logout().logoutUrl("/logout").logoutSuccessUrl("/")
// 其餘所有請求全部需要鑑權認證
.and().authorizeRequests().anyRequest().authenticated()
// 由於使用的是JWT,我們這裏不需要csrf
.and().csrf().disable();
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
主要講解一下
protected void configure(AuthenticationManagerBuilder auth) throws Exception
這個方法是用來驗證用戶信息的。將前端輸入的用戶名和密碼與數據庫匹配,如果有這個用戶才能認證成功。我們注入了一個UserServiceDetail
,這個service的功能就是驗證。.passwordEncoder(passwordEncoder())
是使用加鹽解密。
UserServiceDetail
實現了UserDetailsService
接口,所以需要實現唯一的方法
package zcs.oauthserver.service;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import zcs.oauthserver.model.UserModel;
import java.util.ArrayList;
import java.util.List;
@Service
public class UserServiceDetail implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE"));
return new UserModel("user","user",authorities);
}
}
這裏先用假參數實現功能,後面添加數據庫
參數s是前端輸入的用戶名,通過該參數查找數據庫,獲取密碼和角色權限,最後將這三個數據封裝到UserDetails
接口的實現類中返回。這裏封裝的類可以使用org.springframework.security.core.userdetails.User
或者自己實現UserDetails
接口。
UserModel
實現UserDetails
接口
package zcs.oauthserver.model;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.Collection;
import java.util.List;
public class UserModel implements UserDetails {
private String userName;
private String password;
private List<SimpleGrantedAuthority> authorities;
public UserModel(String userName, String password, List<SimpleGrantedAuthority> authorities) {
this.userName = userName;
this.password = new BCryptPasswordEncoder().encode(password);;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return userName;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
新增username、password和authorities,最後一個存儲的是該用戶的權限列表,也就是用戶擁有能夠訪問哪些資源的權限。密碼加鹽處理。
配置Oauth2認證服務器
新建配置類AuthorizationServerConfig 繼承 AuthorizationServerConfigurerAdapter,並添加@Configuration
@EnableAuthorizationServer註解表明是一個認證服務器
重寫三個函數
ClientDetailsServiceConfigurer
:用來配置客戶端詳情服務,客戶端詳情信息在這裏進行初始化,你能夠把客戶端詳情信息寫死在這裏或者是通過數據庫來存儲調取詳情信息。客戶端就是指第三方應用AuthorizationServerSecurityConfigurer
:用來配置令牌端點(Token Endpoint)的安全約束.AuthorizationServerEndpointsConfigurer
:用來配置授權(authorization)以及令牌(token)的訪問端點和令牌服務(token services)。
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
//從WebSecurityConfig加載
@Autowired
private AuthenticationManager authenticationManager;
//內存存儲令牌
private TokenStore tokenStore = new InMemoryTokenStore();
/**
* 配置客戶端詳細信息
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
//客戶端ID
.withClient("zcs")
.secret(new BCryptPasswordEncoder().encode("zcs"))
//權限範圍
.scopes("app")
//授權碼模式
.authorizedGrantTypes("authorization_code")
//隨便寫
.redirectUris("www.baidu.com");
// clients.withClientDetails(new JdbcClientDetailsService(dataSource));
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore)
.authenticationManager(authenticationManager);
}
/**
* 在令牌端點定義安全約束
* 允許表單驗證,瀏覽器直接發送post請求即可獲取tocken
* 這部分寫這樣就行
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
// 開啓/oauth/token_key驗證端口無權限訪問
.tokenKeyAccess("permitAll()")
// 開啓/oauth/check_token驗證端口認證權限訪問
.checkTokenAccess("isAuthenticated()")
.allowFormAuthenticationForClients();
}
}
客戶端詳細信息同樣也是測試用,後續會加上數據庫。令牌服務暫時是用內存存儲,後續加上jwt。
先實現功能最重要,複雜的東西一步步往上加。
配置資源服務器
資源服務器也就是服務程序,是需要訪問的服務器
新建ResourceServerConfig繼承ResourceServerConfigurerAdapter
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http
// antMatcher表示只能處理/user的請求
.antMatcher("/user/**")
.authorizeRequests()
.antMatchers("/user/test1").permitAll()
.antMatchers("/user/test2").authenticated()
// .antMatchers("user/test2").hasRole("USER")
// .anyRequest().authenticated()
;
}
}
ResourceServerConfigurerAdapter
的Order默認值是3,小於WebSecurityConfigurerAdapter
,值越小優先級越大
關於ResourceServerConfigurerAdapter
和WebSecurityConfigurerAdapter
的詳細說明見
https://www.jianshu.com/p/fe1194ca8ecd
新建UserController
@RestController
public class UserController {
@GetMapping("/user/me")
public Principal user(Principal principal) {
return principal;
}
@GetMapping("/user/test1")
public String test() {
return "test1";
}
@GetMapping("/user/test2")
public String test2() {
return "test2";
}
}
測試
- 獲取code
瀏覽器訪問http://127.0.0.1:9120/oauth/authorize?client_id=zcs&response_type=code&redirect_uri=www.baidu.com
,然後跳出登陸頁面,
認證
地址欄會出現回調頁面,並且帶有code參數 http://127.0.0.1:9120/oauth/www.baidu.com?code=FGQ1jg
- 獲取token
postman訪問http://127.0.0.1:9120/oauth/token?code=FGQ1jg&grant_type=authorization_code&redirect_uri=www.baidu.com&client_id=zcs&client_secret=zcs
,code填寫剛纔得到的code,使用POST請求
- 訪問資源
/user/test2是受保護資源,我們通過令牌訪問
二、升級版
JWT
有很多人會把JWT和OAuth2來作比較,其實它倆是完全不同的概念,沒有可比性。
JWT是一種認證協議,提供一種用於發佈接入令牌、並對發佈的簽名接入令牌進行驗證的方法。
OAuth2是一種授權框架,提供一套詳細的授權機制。
Spring Cloud OAuth2集成了JWT作爲令牌管理,因此使用起來很方便
JwtAccessTokenConverter
是用來生成token的轉換器,而token令牌默認是有簽名的,且資源服務器需要驗證這個簽名。此處的加密及驗籤包括兩種方式:
對稱加密、非對稱加密(公鑰密鑰)
對稱加密需要授權服務器和資源服務器存儲同一key值,而非對稱加密可使用密鑰加密,暴露公鑰給資源服務器驗籤,本文中使用非對稱加密方式。
通過jdk工具生成jks證書,通過cmd進入jdk安裝目錄的bin下,運行命令
keytool -genkeypair -alias oauth2-keyalg RSA -keypass mypass -keystore oauth2.jks -storepass mypass
會在當前目錄生成oauth2.jks文件,放入resource目錄下。
maven默認不加載resource目錄下的文件,所以需要在pom.xml中配置,在build下添加配置
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>**/*.*</include>
</includes>
</resource>
</resources>
</build>
在原來的AuthorizationServerConfig中更改部分代碼
@Autowired
private TokenStore tokenStore;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// endpoints.tokenStore(tokenStore)
// .authenticationManager(authenticationManager);
endpoints.authenticationManager(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.tokenStore(tokenStore);
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* 非對稱加密算法對token進行簽名
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
final JwtAccessTokenConverter converter = new CustomJwtAccessTokenConverter();
// 導入證書
KeyStoreKeyFactory keyStoreKeyFactory =
new KeyStoreKeyFactory(new ClassPathResource("oauth2.jks"), "mypass".toCharArray());
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("oauth2"));
return converter;
}
jwtAccessTokenConverter
方法中有一個CustomJwtAccessTokenConverter
類,這是繼承了JwtAccessTokenConverter
,自定義添加了額外的token信息
/**
* 自定義添加額外token信息
*/
public class CustomJwtAccessTokenConverter extends JwtAccessTokenConverter {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
DefaultOAuth2AccessToken defaultOAuth2AccessToken = new DefaultOAuth2AccessToken(accessToken);
Map<String, Object> additionalInfo = new HashMap<>();
UserModel user = (UserModel)authentication.getPrincipal();
additionalInfo.put("USER",user);
defaultOAuth2AccessToken.setAdditionalInformation(additionalInfo);
return super.enhance(defaultOAuth2AccessToken,authentication);
}
}
Security
之前登陸是用假數據,現在通過連接數據庫進行驗證。
建立三個表,user存儲用戶賬號和密碼,role存儲角色,user_role存儲用戶的角色
user表
role表
user_role表
使用mybatis-plus生成代碼,改造之前的UserServiceDetail
和UserModel
UserServiceDetail
@Service
public class UserServiceDetail implements UserDetailsService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
@Autowired
public UserServiceDetail(UserMapper userMapper, RoleMapper roleMapper) {
this.userMapper = userMapper;
this.roleMapper = roleMapper;
}
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("username", s);
User user = userMapper.selectOne(userQueryWrapper);
if (user == null) {
throw new RuntimeException("用戶名或密碼錯誤");
}
user.setAuthorities(roleMapper.selectByUserId(user.getId()));
return user;
}
}
通過UserMapper查詢用戶信息,然後封裝到User中,在自動生成的User上實現UserDetails接口
User
public class User implements Serializable, UserDetails {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableId(value = "username")
private String username;
@TableId(value = "password")
private String password;
@TableField(exist = false)
private List<Role> authorities;
public User() {
}
public Integer getId() {
return id;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = new BCryptPasswordEncoder().encode(password);
}
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;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String toString() {
return "User{"
"id=" id
", username=" username
", password=" password
"}";
}
}
解釋說明:
UserDetails中需要重寫一個方法,是存儲用戶權限的
@Override
public Collection<? extends GrantedAuthority> getAuthorities()
所以新增了一個變量,並且打上註解表示這不是一個字段屬性
@TableField(exist = false)
private List<Role> authorities;
在Role上實現GrantedAuthority接口,只需要權限名稱就可以了
public class Role implements Serializable, GrantedAuthority {
private static final long serialVersionUID = 1L;
private String name;
@Override
public String toString() {
return name;
}
@Override
public String getAuthority() {
return name;
}
}
在RoleMapper.java中新增方法,通過用戶id查詢擁有的角色
@Select("select name from role r INNER JOIN user_role ur on ur.user_id=1 and ur.role_id=r.id")
List<Role> selectByUserId(Integer id);
測試
測試方法和第一部分一樣,獲取令牌的時候返回如下
參考鏈接:
https://www.cnblogs.com/fp2952/p/8973613.html
https://juejin.im/post/5c5ae6566fb9a049b3486e38
更多文章見個人博客 https://zheyday.github.io/