Spring boot整合Oauth2实现单点登陆(授权码模式)
本次使用spring boot 2.2.7开发,过程中遇到一些问题记录一下。
认证服务端
pom文件示例
<dependencies>
<!-- 使用redis存储token -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- oauth2 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 自定义登陆界面 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
注意!!这里应用的是spring-cloud-starter-oauth2
,所以pom需要指定springcloud的版本,并声明springcloud的dependencyManagement
<properties>
<java.version>1.8</java.version>
<!-- 这里springboot版本为2.2.7,对应cloud版本是这个 -->
<spring-cloud.version>Hoxton.SR4</spring-cloud.version>
</properties>
<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>
系统配置文件application.yml
server:
port: 8080
spring:
#redis配置信息
redis:
..........
#jpa配置信息
jpa:
..........
#数据库相关信息
datasource:
..........
#模板引擎
thymeleaf:
..........
#开启debug日志
logging:
level:
org.springframework: debug
实体类
这里身份验证有两步,首先spring security
验证用户名,密码,然后跳转到/oauth/authorize
验证客户端的信息,之后授权再返回授权码。
我们需要建两个实体类,用户的和客户端的。(这里的客户端指的是应用,比如文章后半段那个测试客户端)
用户实体UserInfo
用户实体字段可以根据实际情况定义,只有id用户名密码也可以。这里的例子是电话号码telno
作为用户名。
@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo{
@Id
private long id;
private String password;
private long telno;
.........
}
客户端实体OauthClientDetails
客户端实体字段要求会严格一些,字段名不一定要一样,但是一定要有,获取授权码时会用到,其他个性化字段也可以加。
@Data
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class OauthClientDetails {
@Id
private String clientId;
private String clientSecret;
private String authorities;
private String authorizedGrantTypes;
private String resourceIds;
private String webServerRedirectUri;
private String scope;
private int accessTokenValidity;
private int refreshTokenValidity;
@Column(length = 4096)
private String additionalInformation;
private String autoapprove;
}
实体对应repository
public interface ClientDetailsRepository
extends JpaRepository<OauthClientDetails, String> {
}
public interface UserInfoRepository extends JpaRepository<UserInfo, Integer>{
//根据telno查询用户信息
public Optional<UserInfo> findUserInfoByTelno(Long telno);
}
新建user与client对应的service
用户信息UserDetailsServiceImpl
根据security的规则,需要继承UserDetailsService
,实现loadUserByUsername
方法:
@Service("testUserDetailService")//设置bean name,防止spring bean注入时冲突
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserInfoRepository userInfoRepository;
@Autowired
public UserDetailsServiceImpl(UserInfoRepository userInfoRepository) {
this.userInfoRepository = userInfoRepository;
}
@Override
public UserDetails loadUserByUsername(String arg0)
throws UsernameNotFoundException {
return userInfoRepository.findUserInfoByTelno(Long.parseLong(arg0))
.map(userInfo -> {
User.UserBuilder builder = User.builder();
builder = builder.username(String.valueOf(userInfo.getTelno()));
builder = builder.password(userInfo.getPassword());
builder = builder.accountLocked(false);
//这里手动设置用户角色,可以通过数据库查询结果替换
builder = builder.authorities(
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
return builder.build();
}).orElseThrow(() ->
new UsernameNotFoundException(
String.format("UserName not found.[%s]",arg0)));
}
}
客户端信息ClientDetailsServiceimpl
这里也需要根据规则继承ClientDetailsService
,实现loadClientByClientId
:
@Service("testClientDetailsService")//设置bean name
public class ClientDetailsServiceimpl implements ClientDetailsService {
private final ClientDetailsRepository clientDetailsRepository;
private final ObjectMapper objectMapper;
public ClientDetailsServiceimpl(
ClientDetailsRepository clientDetailsRepository,ObjectMapper objectMapper) {
this.clientDetailsRepository = clientDetailsRepository;
this.objectMapper = objectMapper;
}
@Override
public ClientDetails loadClientByClientId(String arg0)
throws ClientRegistrationException {
Optional<OauthClientDetails> findById = null ;
try {
findById = clientDetailsRepository.findById(arg0);
} catch (Exception e2) {
e2.printStackTrace();
}
//下面都是设置client的属性,仔细看看
return findById
.map(clientDetails -> {
BaseClientDetails details = new BaseClientDetails();
details.setClientId(clientDetails.getClientId());
details.setClientSecret(clientDetails.getClientSecret());
details.setAuthorizedGrantTypes(
Arrays.asList(
clientDetails.getAuthorizedGrantTypes().split(",")));
.................省略,具体查看github
//根据数据库信息自动授权,这样用户登陆可以跳过授权页面
if("true".equalsIgnoreCase(clientDetails.getAutoapprove()))
details.setAutoApproveScopes(
Arrays.asList(
Optional.ofNullable(
clientDetails.getScope()).orElse("").split(",")));
try {
if(clientDetails.getAdditionalInformation()!=null){
details.setAdditionalInformation(
objectMapper.readValue(
clientDetails.getAdditionalInformation(),
HashMap.class));
}
} catch (Exception e) {
e.printStackTrace();
}
return details;
}).orElseThrow(BadClientCredentialsException::new);
}
}
权限验证配置
将redisTokenStore放到spring容器中
@Configuration
public class RedisTokenStoreCOnfig {
@Autowired
private RedisConnectionFactory connect;
@Bean
public RedisTokenStore getRedisTokenStore(){
return new RedisTokenStore(connect);
}
}
配置spring security
测试过程中有出现获取token的路径受限的问题,需要开放权限/oauth/**
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;
//这个我是在启动类声明的bean:BCryptPasswordEncoder
@Autowired
@Qualifier("testUserDetailService")
private UserDetailsService userDetailsService;
@Bean("testAuthenticationManager")
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/static/**","/oauth/**","/test","/auth/login").permitAll()
.antMatchers(HttpMethod.OPTIONS, "/**").anonymous()
.anyRequest().authenticated()
//设置自定义登陆界面
.and().formLogin().loginPage("/auth/login")
//设置登陆界面表单提交路径,springSecurity登录方法拦截地址
.loginProcessingUrl("/oauth/authorize")
.permitAll()
.and().httpBasic()//允许basic,不然进不到授权页面
.and()
.csrf().disable();
}
/**
* 配置spring security验证方式,用户名密码的验证是基于security的
* 所以要在这里设置,不然会报需要先通过spring security验证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
}
}
对于formLogin
需要注意,loginPage
是指定登陆界面,loginProcessingUrl
是指定登陆界面表单提交路径action=''
,设置需要spring security过滤的url,当请求到后台时会进入security对应的过滤器,实现它的登录逻辑,我看网上其他文章很多都说些/login
或者/authentication/form
,是否正确有待检验,根据默认实现的登陆逻辑看,点击登陆之后跳转的路径就是/oauth/authorize
。
分析一下:oauth2在获取授权码时访问的路径是http://localhost:8080/oauth/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:8080/test
。就是说我们不管是手动请求token还是客户端的sso请求token都需要通过这个url访问授权服务器,现在我们自定义登陆界面之后会跳转到登陆界面的url,如果我们表单提交路径不是这个的话spring security的验证也能进行,但是不会被oauth2的过滤器捕获,结果就是spring security验证通过,跳转到默认的主页/
。要解决这个问题就需要自定义登陆成功之后的逻辑successHandler
,成本比较高。
配置oauth2
这个内容网上关于oauth2的文章大同小异,具体有哪些是必须的,还需要再研究一下,这里暂且这样吧。
@Configuration
@EnableAuthorizationServer
@Slf4j
public class Oauth2COnfig extends AuthorizationServerConfigurerAdapter {
@Autowired
@Qualifier("testUserDetailService")//由bean name注入
private UserDetailsService userDetailsService;
@Autowired
@Qualifier("testClientDetailsService")
private ClientDetailsService clientDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
@Qualifier("testAuthenticationManager")
private AuthenticationManager authenticationManager;
@Autowired
private RedisTokenStore redisTokenStore;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
endpoints.tokenStore(redisTokenStore)
.tokenServices(getTokenService())
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
if(log.isDebugEnabled()){
log.debug("AuthorizationServerEndpointsConfigurer configuration finished");
}
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security)
throws Exception {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()")
.passwordEncoder(passwordEncoder);
if(log.isDebugEnabled()){
log.debug("AuthorizationServerSecurityConfigurer configuration finished");
}
}
@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
clients.withClientDetails(clientDetailsService);
if(log.isDebugEnabled()){
log.debug("ClientDetailsServiceConfigurer configuration finished");
}
}
@Bean
public DefaultTokenServices getTokenService(){
DefaultTokenServices service = new DefaultTokenServices();
service.setTokenStore(redisTokenStore);
service.setClientDetailsService(clientDetailsService);
service.setSupportRefreshToken(true);
return service;
}
}
配置resourceServer
注意,测试时发现没设置resourceid会出现请求在login与authentication之间反复横跳,导致重定向过多。还有个大问题要注意,服务端测试时回调地址最好不要写/login
,因为oauth2会拦截这个请求并转到默认主页,没有的话会一直报404,配置了permitall也会这样的。
@Configuration
@EnableResourceServer//声明为一个资源服务器
public class OauthResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
//这里的配置跟security里面的是一个意思,但是我测试几遍发现访问的时候是用的这里的规则
//所以没研究透彻之前保持两边一直,但是formlogin那个经过测试,这里是不用写的,写了也没用
http
.authorizeRequests()
.antMatchers("/oauth/**").permitAll()
.antMatchers("/test").permitAll()//测试的回调路径,不放开会导致获取授权码时错误
.antMatchers("/auth/login").permitAll()
.antMatchers(HttpMethod.OPTIONS, "/**").anonymous()
.anyRequest().authenticated()
.and().httpBasic()//开启后才能进入授权页面
.and()
.csrf().disable();
}
@Override
public void configure(ResourceServerSecurityConfigurer resources)
throws Exception {
resources.resourceId("testresourceid");
}
}
配置结束。
template文件夹下新建login.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>统一验证登陆界面</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
</head>
<body>
<div class="container" style="margin-top:250px">
<div class="row clearfix">
<div class="col-md-4 column">
</div>
<div class="col-md-4 column">
<form class="form-horizontal" action="/oauth/authorize" method="post" role="form">
<div class="form-group">
<label for="username" class="col-sm-2 control-label">账号</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="username" name="username" />
</div>
</div>
<div class="form-group">
<label for="password" class="col-sm-2 control-label">密码</label>
<div class="col-sm-8">
<input type="password" class="form-control" id="password" name = "password" />
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label><input type="checkbox" />记住我</label>
</div>
</div>
</div>
<div class="form-group">
<div class=" col-sm-10">
<button type="submit" class="btn btn-primary btn-lg btn-block">登陆</button>
</div>
</div>
</form>
</div>
<div class="col-md-4 column">
</div>
</div>
</div>
</body>
<!-- 最新的 Bootstrap 核心 JavaScript 文件 -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
</html>
controller示例
@GetMapping("/auth/login")
public ModelAndView formlogin(ModelAndView mv){
mv.setViewName("/login.html");
return mv;
}
postman测试
数据库 客户端信息示例
-
浏览器访问
http://localhost:8080/oauth/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:8080/test
,需要注意的是,client_id与redirect_uri必须对应数据库内的数据,uri不存在会不允许跳转 -
成功跳转之后会在地址栏出现code的参数。使用postman获取token,url为
/oauth/token
- 设置请求头身份信息,client_secret可以为空,根据数据库内容定义,headers里面添加content-type为application/x-www-form-urlencoded。
-
设置参数
-
结果示例
{ "access_token": *******************, "token_type": "bearer", "refresh_token": "*******************", "expires_in": 7199, "scope": "read write" }
认证服务器的内容就这些了。下面是客户端的内容。
客户端示例
客户端只需要配置security,再加上@EnableOAuth2Sso
,这样会加入sso相关的filter,实现获取token的过程。
pom文件示例
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
配置文件示例
注意!!server.servlet.session.cookie.name
必须设置,不然会导致登陆之后跳转不到我们需要请求的路径。具体原因百度一下。
#认证服务器地址
auth-server: http://*****:8080
server:
port: 9090
servlet:
session:
cookie:
name: sso-client
security:
basic:
enable: false
#sso配置,对应服务端数据库的client的一条信息
oauth2:
client:
clientId: test
clientSecret: test-8080
accessTokenUri: ${auth-server}/oauth/token
userAuthorizationUri: ${auth-server}/oauth/authorize
scope: read,write
auto-approve-scopes: '.*'
resource:
userInfoUri: ${auth-server}/user
logging:
level:
org.springframework: debug
客户端security配置
注意把/oauth
路径放开,不然可能会导致访问不到。
加入@EnableOAuth2Sso
,这个注解只能加在WebSecurityConfigurerAdapter
实现类上,因为它有自己默认的WebSecurityConfigurerAdapter
实现,当它标注的类不是继承的WebSecurityConfigurerAdapter
,那么它会加载默认的配置类,会导致冲突。
@Configuration
@EnableOAuth2Sso
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().antMatchers("/oauth/**").permitAll()
.........
.and()
.csrf().disable();
}
}
基本的单点登录功能就实现了,访问客户端受限url时会先去认证服务器获取token,以同样的方式新建多个客户端,对应服务端的多条client信息,可以免登录。这里实现的客户端对应的就是认证服务器的client。有兴趣的话可以把服务端的formlogin先去掉,debug看看。
认证服务器github地址:https://github.com/superDSB/ssoServerDemo
当它标注的类不是继承的WebSecurityConfigurerAdapter
,那么它会加载默认的配置类,会导致冲突。
@Configuration
@EnableOAuth2Sso
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().antMatchers("/oauth/**").permitAll()
.........
.and()
.csrf().disable();
}
}
基本的单点登录功能就实现了,访问客户端受限url时会先去认证服务器获取token,以同样的方式新建多个客户端,对应服务端的多条client信息,可以免登录。这里实现的客户端对应的就是认证服务器的client。有兴趣的话可以把服务端的formlogin先去掉,debug看看。
认证服务器github地址:https://github.com/superDSB/ssoServerDemo
客户端示例github地址:https://github.com/superDSB/ssoclientdemo