前言
搭建Spring Security OAuth2密码模式demo使用postman测试,一方面在慢慢完善使用oauth2过程中做一个系统的总结,一方面做一个知识输出
一、认证授权服务搭建
- 添加依赖
<properties>
<java.version>1.8</java.version>
<spring.boot.version>2.3.12.RELEASE</spring.boot.version>
<spring-cloud-starter-oauth2.version>2.2.5.RELEASE</spring-cloud-starter-oauth2.version>
<slf4j.version>1.7.21</slf4j.version>
</properties>
<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>
<version>${spring-cloud-starter-oauth2.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.37</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.4.M1</version>
</dependency>
<!--jwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!--jdbc-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
- AuthorizationServerConfig.java
/**
* @Author LY
* @ClassName AuthorizationServerConfig
* @Date 2023/6/10 14:42
* @Description 认证服务器配置
* @Version 1.0
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private UserServiceImpl userDetailsService;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private DataSource dataSource;
@Autowired
private RedisConnectionFactory redisConnectionFactory;
/**
* 初始化 redisTokenStore 用户将token 放入redis
* @return
*/
@Bean
public RedisTokenStore redisTokenStore(){
RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
redisTokenStore.setPrefix("TOKEN:");
return redisTokenStore;
}
/**
* 设置认证令牌放行
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
//允许所有资源服务器访问公钥端点(/oauth/token_key)
//只允许验证用户访问令牌解析端点(/oauth/check_token)
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
//允许表单验证
.allowFormAuthenticationForClients();//主要是让/oauth/token支持client_id以及client_secret作登录认证
}
/**
* 配置授权以及令牌的访问端点和令牌服务
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//认证信息从数据库获取
clients.withClientDetails(clientDetails());
// // 使用内存操作测试
// clients.inMemory()
// .withClient("client-app")
// .secret(new BCryptPasswordEncoder().encode("123456"))
// .resourceIds("resId")
// .scopes("all")
// .authorizedGrantTypes("password", "refresh_token", "client_credentials")
// .and()
// .withClient("client-app2")
// .secret(new BCryptPasswordEncoder().encode("123456"))
// .resourceIds("resId2")
// .scopes("all")
// .authorizedGrantTypes("password", "refresh_token", "client_credentials")
// .accessTokenValiditySeconds(3600)
// .refreshTokenValiditySeconds(86400);
}
@Bean
public ClientDetailsService clientDetails() {
// 自定义实现
return new CustomJdbcClientDetailsService(dataSource);
// 默认实现
// return new JdbcClientDetailsService(dataSource);
}
/**
* 配置令牌端点的安全约束
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
// 请求方式
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
// 指定认证管理器
.authenticationManager(authenticationManager)
// 用户账号密码认证
.userDetailsService(userDetailsService)
// .accessTokenConverter(jwtAccessTokenConverter())
// 指定token存储位置
.tokenStore(redisTokenStore())
// 令牌增强对象 , 增强返回的结果
.tokenEnhancer((accessToken, authentication) -> {
// 获取用户信息,然后设置
SecurityUser user = (SecurityUser) authentication.getPrincipal();
LinkedHashMap<String, Object> map = new LinkedHashMap<>();
map.put("userId",user.getId());
map.put("usernmae",user.getUsername());
map.put("enabled", user.getEnabled());
DefaultOAuth2AccessToken token = (DefaultOAuth2AccessToken) accessToken;
token.setAdditionalInformation(map);
return token;
});
}
/*------------使用jwt配置token-------------*/
// @Bean
// public TokenStore jwtTokenStore(){
// JwtTokenStore jwtTokenStore = new JwtTokenStore(jwtAccessTokenConverter());
// return jwtTokenStore;
// }
//
// @Bean
// public JwtAccessTokenConverter jwtAccessTokenConverter(){
// JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
// jwtAccessTokenConverter.setSigningKey("SigningKey");//设置对称签名秘钥
// return jwtAccessTokenConverter;
// }
}
- ResourceServerConfig.java
/**
* @Author LY
* @ClassName ResourceServerConfig
* @Date 2023/6/10 22:27
* @Description 资源服务器配置
* @Version 1.0
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
// 配置资源id,这里的资源id和授权服务器中的资源id一致
resources.resourceId("resId")
// 设置这些资源仅基于令牌认证
.stateless(true);
}
/**
* 配置 URL 访问权限
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated();
}
}
- WebSecurityConfig.java
/**
* @Author LY
* @ClassName WebSecurityConfig
* @Date 2023/6/10 14:52
* @Description SpringSecurity授权配置
* @Version 1.0
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private UserServiceImpl userDetailsService;
@Bean
@Override
protected UserDetailsService userDetailsService() {
return super.userDetailsService();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 从数据库读取配置
auth.userDetailsService(userDetailsService);
// 使用内存操作
// auth
// .inMemoryAuthentication()
// .withUser("admin").password(new BCryptPasswordEncoder().encode("123456")).roles("ADMIN")
// .and()
// .withUser("andy").password(new BCryptPasswordEncoder().encode("123456")).roles("TEST");
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 拦截配置
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()//配置需要认证的请求
.antMatchers("/oauth/**").permitAll() //放行
.anyRequest().authenticated()//其他请求需要认证
.and().csrf().disable()
// 禁用session,因为我们使用的是token,session没有用途
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
核心配置基本完成,下面配置自定义UserDetails,UserDetailsService,JdbcClientDetailsService
- UserDetails自定义 这个类也是从网上摘取的,可自行进行扩展改造
/**
* @Author LY
* @ClassName SecurityUser
* @Date 2023/6/10 14:58
* @Description Security用户信息
* @Version 1.0
*/
@Data
public class SecurityUser implements UserDetails {
/**
* ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 用户密码
*/
private String password;
/**
* 用户状态
*/
private Boolean enabled;
/**
* 权限数据
*/
private Collection<SimpleGrantedAuthority> authorities;
public SecurityUser() {}
public SecurityUser(UserDTO userDTO) {
this.setId(userDTO.getId());
this.setUsername(userDTO.getUsername());
this.setPassword(userDTO.getPassword());
this.setEnabled(userDTO.getStatus() == 1);
if (userDTO.getRoles() != null) {
authorities = new ArrayList<>();
userDTO.getRoles().forEach(item -> authorities.add(new SimpleGrantedAuthority(item)));
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
}
/**
* @Author LY
* @ClassName UserDTO
* @Date 2023/6/10 14:56
* @Description 用户信息
* @Version 1.0
*/
@Data
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
public class UserDTO{
private Long id;
private String username;
private String password;
private Integer status;
private List<String> roles;
}
- UserDetailsService自定义
/**
* @Author LY
* @ClassName UserServiceImpl
* @Date 2023/6/10 14:54
* @Description 用户信息校验
* @Version 1.0
*/
@Service
@Slf4j
public class UserServiceImpl implements UserDetailsService {
private List<UserDTO> userList;
@Autowired
private PasswordEncoder passwordEncoder;
@PostConstruct
public void initData() {
String password = passwordEncoder.encode("123456");
userList = new ArrayList<>();
userList.add(new UserDTO(1L,"admin", password,1, CollUtil.toList("ADMIN")));
userList.add(new UserDTO(2L,"andy", password,1, CollUtil.toList("TEST")));
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("当前登录用户:{}",username);
// 这里使用上面定义的用户及权限认证,可以换成自己的从数据库读取
List<UserDTO> findUserList = userList.stream().filter(item -> item.getUsername().equals(username)).collect(Collectors.toList());
if (CollUtil.isEmpty(findUserList)) {
throw new UsernameNotFoundException("用户名或密码错误!");
}
SecurityUser securityUser = new SecurityUser(findUserList.get(0));
if (!securityUser.isEnabled()) {
throw new DisabledException("该账户已被禁用,请联系管理员!");
} else if (!securityUser.isAccountNonLocked()) {
throw new LockedException("该账号已被锁定,请联系管理员!");
} else if (!securityUser.isAccountNonExpired()) {
throw new AccountExpiredException("该账号已过期,请联系管理员!");
} else if (!securityUser.isCredentialsNonExpired()) {
throw new CredentialsExpiredException("该账户的登录凭证已过期,请重新登录!");
}
// String password = passwordEncoder.encode("123456");
// CustomUserDetails user = new CustomUserDetails(username,password, AuthorityUtils.
// commaSeparatedStringToAuthorityList("ROLE_ADMIN"));
// return user;
return securityUser;
}
}
- JdbcClientDetailsService自定义
/**
* @Author LY
* @create 2023/6/7 11:17
* @Description 自定义jdbc数据库读取信息
*/
@Slf4j
public class CustomJdbcClientDetailsService extends JdbcClientDetailsService {
//操作oauth_client_details数据库SQL语句,另外可以自行注入
private static final String CLIENT_FIELDS_FOR_UPDATE = "resource_ids, scope, "
+ "authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, "
+ "refresh_token_validity, additional_information, autoapprove";
private static final String CLIENT_FIELDS = "client_secret, " + CLIENT_FIELDS_FOR_UPDATE;
private static final String BASE_FIND_STATEMENT = "select client_id, " + CLIENT_FIELDS + " from oauth_client_details";
private static final String DEFAULT_FIND_STATEMENT = BASE_FIND_STATEMENT + " order by client_id";
private static final String DEFAULT_SELECT_STATEMENT = BASE_FIND_STATEMENT + " where client_id = ?";
private static final String DEFAULT_INSERT_STATEMENT = "insert into oauth_client_details (" + CLIENT_FIELDS
+ ", client_id) values (?,?,?,?,?,?,?,?,?,?,?)";
private static final String DEFAULT_UPDATE_STATEMENT = "update oauth_client_details " + "set "
+ CLIENT_FIELDS_FOR_UPDATE.replaceAll(", ", "=?, ") + "=? where client_id = ?";
private static final String DEFAULT_UPDATE_SECRET_STATEMENT = "update oauth_client_details "
+ "set client_secret = ? where client_id = ?";
private static final String DEFAULT_DELETE_STATEMENT = "delete from oauth_client_details where client_id = ?";
//1.用于client_secret密码入库与出库时转化
private PasswordEncoder passwordEncoder = NoOpPasswordEncoder.getInstance();
//2.数据库存操作。
private final JdbcTemplate jdbcTemplate;
private JdbcListFactory listFactory;
public CustomJdbcClientDetailsService(DataSource dataSource) {
super(dataSource);
Assert.notNull(dataSource, "DataSource required");
this.jdbcTemplate = new JdbcTemplate(dataSource);
this.listFactory = new DefaultJdbcListFactory(new NamedParameterJdbcTemplate(jdbcTemplate));
}
/**
* 核心方法。加载ClientDetails by clientId
*/
@Override
public ClientDetails loadClientByClientId(String clientId) throws InvalidClientException {
log.info("==== 加载ClientDetails by clientId:{} ====",clientId);
ClientDetails details = null;
try {
details = jdbcTemplate.queryForObject(DEFAULT_SELECT_STATEMENT, new ClientDetailsRowMapper(), clientId);
}
catch (EmptyResultDataAccessException e) {
throw new NoSuchClientException("No client with requested id: " + clientId);
}
return details;
}
// 重新映射字段显示 需要哪些字段添加哪些
static class ClientDetailsRowMapper implements RowMapper<ClientDetails> {
@Override
public ClientDetails mapRow(ResultSet rs, int rowNum) throws SQLException {
// 筛选值
BaseClientDetails details = new BaseClientDetails();
details.setClientId(rs.getString("client_id"));
details.setClientSecret(rs.getString("client_secret"));
details.setResourceIds(StringUtils.commaDelimitedListToSet(rs.getString("resource_ids")));
details.setScope(StringUtils.commaDelimitedListToSet(rs.getString("scope")));
details.setAuthorizedGrantTypes(StringUtils.commaDelimitedListToSet(rs.getString("authorized_grant_types")));
details.setAuthorities(getAuthority(rs.getString("authorities")));
return details;
}
private Collection<? extends GrantedAuthority> getAuthority(String authorities) {
if (!StringUtils.hasText(authorities)) {
return new ArrayList<>();
}
return AuthorityUtils.commaSeparatedStringToAuthorityList(authorities);
}
}
}
- yml配置
server:
port: 10001
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/permission
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
# redis 配置
redis:
# 地址
host: 127.0.0.1
# 端口,默认为6379
port: 6379
# 密码
password:
logging:
level:
org:
springframework:
# 打印security日志
security: debug
测试认证:http://localhost:10001/oauth/token
grant_type:password
client_id:client-app
client_secret:123456
username:admin
password:123456
可以看到成功获取了access_token
测试授权我们先需要添加测试控制层AuthController.java
@RestController
public class AuthController {
// 需要ADMIN权限访问
@PreAuthorize("hasAuthority('ADMIN')")
@RequestMapping(value = "/getStr1",method = {RequestMethod.GET})
public String getStr1(){
return "getStr1";
}
// 需要TEST权限访问
@PreAuthorize("hasAuthority('TEST')")
@RequestMapping(value = "/getStr2",method = {RequestMethod.GET})
public String getStr2(){
return "getStr2";
}
@RequestMapping(value = "/getStr3",method = {RequestMethod.GET})
public String getStr3(){
return "getStr3";
}
}
授权测试
测试地址:http://localhost:10001/getStr1
测试地址:http://localhost:10001/getStr2
可以看到访问getStr1能正常访问,访问getStr2提示“不允许访问”,因为我们当前登录的账号只有ADMIN权限,测试结果复核预期
资源服务id测试
从上面的配置我们指定认证授权服务配置的client_id=client-app属于resourceId=resId,另一个client_id=client-app2属于resourceId=resId2
可以看到当前登录的用户属于resourceId=resId资源并没有其他资源访问权限,测试期望预期,认证授权服务端测试结束
我们可以看下redis存储的TOKEN信息:
二、资源服务搭建
<properties>
<java.version>1.8</java.version>
<spring.boot.version>2.3.12.RELEASE</spring.boot.version>
<spring-cloud-starter-oauth2.version>2.2.5.RELEASE</spring-cloud-starter-oauth2.version>
<slf4j.version>1.7.21</slf4j.version>
</properties>
<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>
<version>${spring-cloud-starter-oauth2.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.4.M1</version>
</dependency>
</dependencies>
- ResourceServerConfigurerAdapter.java
/**
* @Author LY
* @ClassName WebSecurityConfig
* @Date 2023/6/10 17:58
* @Description 资源服务配置
* @Version 1.0
*/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
// 配置资源id,这里的资源id和授权服务器中的资源id一致
resources.resourceId("resId2")
.stateless(true);
}
/**
* 配置 URL 访问权限
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests()
.antMatchers("/oauth/**").permitAll() //设置/oauth/**的接口不需要授权就可以访问
.anyRequest().authenticated();
}
}
- yml配置
server:
port: 10002
#spring:
# datasource:
# url: jdbc:mysql://127.0.0.1:3306/permission
# username: root
# password: root
# driver-class-name: com.mysql.jdbc.Driver
security:
oauth2:
client:
# 客户端id
client-id: client-app2
# 客户端密钥
client-secret: 123456
# resource-ids: resId2
access-token-uri: http://127.0.0.1:10001/oauth/token
user-authorization-uri: http://127.0.0.1:10001/oauth/authorize
resource:
# 认证服务器验证token的请求接口
token-info-uri: http://127.0.0.1:10001/oauth/check_token
# 获取用户信息
user-info-uri: http://127.0.0.1:10001/users/current
logging:
level:
org:
springframework:
# security日志打印
security: debug
- 控制层
/**
* @Author LY
* @ClassName SystemController
* @Date 2023/6/10 17:56
* @Description 测试资源服务接口权限
* @Version 1.0
*/
@RestController
public class SystemController {
@PreAuthorize("hasAuthority('ADMIN')")
@RequestMapping(value = "/getSys1",method = {RequestMethod.GET})
public String getSys1(){
final SecurityContext context = SecurityContextHolder.getContext();
System.out.println(context.getAuthentication());
return "sys1";
}
@PreAuthorize("hasAuthority('TEST')")
@RequestMapping(value = "/getSys2",method = {RequestMethod.GET})
public String getSys2(){
return "sys2";
}
@RequestMapping(value = "/getSys3",method = {RequestMethod.GET})
public String getSys3(){
// 登录信息
final SecurityContext context = SecurityContextHolder.getContext();
System.out.println(context.getAuthentication().isAuthenticated());
System.out.println(context.getAuthentication().getAuthorities());
System.out.println(context.getAuthentication().getPrincipal());
System.out.println(context.getAuthentication().getDetails());
return "sys3";
}
}
资源服务搭建完成,接下来使用postman测试接口:
可以看见当前登录的用户是存在ADMIN权限而没有TEST权限,测试期望预期
接下来测试资源服务resourceId验证:
可以看到当前登录的用户属于resourceId=resId2资源并没有其他资源访问权限,测试期望预期,资源服务端测试访问资源id验证结束
总结: 整个认证授权测试结束,其中测试了使用注解限制,测试了resourceId资源服务限制,测试结果基本符合我们的期望,到此使用SpringBoot+security+oauth2搭建demo完成