本文使用spring-security-oauth2实现OAuth2认证服务。
源码地址:https://github.com/kangarooxin/spring-security-oauth2-demo
- pom.xml加入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.8.RELEASE</version>
</dependency>
- 配置SpringSecurity
@Configuration
@EnableWebSecurity //启用SpringSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 配置Security处理授权服务器相关请求
*
* @param httpSecurity
* @throws Exception
*/
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.requestMatchers().antMatchers("/oauth/**", "/login", "/logout")//配置需要Security拦截的请求
.and().formLogin().permitAll().loginPage("/login")//指定登录页面
.and().logout().logoutUrl("/logout")//指定登出页面
.and().authorizeRequests().anyRequest().authenticated()//其它页面都需要鉴权认证
.and().csrf().disable()//不开启csrf
;
}
/**
* 用户服务
*
* 此处在内存创建了2个用户,实际场景替换为db服务查询,只需要实现UserDetailsService即可
*
* @return
*/
@Bean
@Override
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("user1").password(passwordEncoder.encode("123456")).authorities("USER").build());
manager.createUser(User.withUsername("user2").password(passwordEncoder.encode("123456")).authorities("USER").build());
return manager;
}
/**
* 设置密码校验器
* NoOpPasswordEncoder 直接文本比较 equals
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- 配置认证服务AuthorizationServer
@Configuration
@EnableAuthorizationServer //启用认证服务
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* *用来配置令牌端点(Token Endpoint)的安全约束。
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients()//默认/oauth/token认证是使用BasicAuth,此处设置允许通过表单URL提交client_id和client_secret。
.passwordEncoder(passwordEncoder)//设置密码编码器, 使用client_id登录时,密码加密要跟用户加密一致。
.checkTokenAccess("isAuthenticated()")//开启/oauth/check_token验证端口认证权限访问
;
}
/**
* 配置OAuth2的客户端相关信息
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(oauth2ClientDetailsService());
}
/**
* 配置授权服务器端点的属性
*
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.tokenStore(oauth2TokenStore())//指定token存储服务
.authorizationCodeServices(authorizationCodeServices())//指定code生成服务
.userDetailsService(userDetailsService)//用户服务
.reuseRefreshTokens(false)//每次刷新token会创建新的refresh_token
;
}
/**
* 设置令牌存储方式
* InMemoryTokenStore 在内存中存储令牌。
* RedisTokenStore 在Redis缓存中存储令牌。
* JwkTokenStore 支持使用JSON Web Key (JWK)验证JSON Web令牌(JwT)的子Web签名(JWS)
* JwtTokenStore 不是真正的存储,不持久化数据,身份和访问令牌可以相互转换。
* JdbcTokenStore 在数据库存储,需要创建相应的表存储数据
*/
@Bean
public TokenStore oauth2TokenStore() {
return new InMemoryTokenStore();
}
/**
* 设置Code生成服务
*
* @return
*/
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new InMemoryAuthorizationCodeServices();
}
/**
* client服务
*
* @return
* @throws Exception
*/
@Bean
public ClientDetailsService oauth2ClientDetailsService() throws Exception {
InMemoryClientDetailsServiceBuilder builder = new InMemoryClientDetailsServiceBuilder();
builder.withClient("clientId")
.secret(passwordEncoder.encode("clientSecret"))
.scopes("scope1", "scope2")
.authorizedGrantTypes("client_credentials", "password", "authorization_code", "refresh_token")//支持的授权类型
.redirectUris("http://www.baidu.com") //回调url
.autoApprove(false) //允许自动授权
.accessTokenValiditySeconds((int)TimeUnit.HOURS.toSeconds(12))
.refreshTokenValiditySeconds((int)TimeUnit.DAYS.toSeconds(30))
;
return builder.build();
}
}
- 配置资源服务ResourceServer
@Configuration
@EnableResourceServer //启用资源服务
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.requestMatchers().antMatchers("/api/**") //仅拦截资源服务相关请求
.and().authorizeRequests()
.antMatchers("/api/group1/**").access("#oauth2.hasScope('scope1')") //配置scope访问权限
.antMatchers("/api/group2/**").access("#oauth2.hasScope('scope2')")
.anyRequest().authenticated();
}
}
- 创建资源API
@RestController
@RequestMapping("/api/group1")
public class Group1Controller {
@RequestMapping
public String hello() {
return "hello group1";
}
}
@RestController
@RequestMapping("/api/group2")
public class Group2Controller {
@RequestMapping
public String hello() {
return "hello group2";
}
}
- 授权测试
- 客户端凭证(client_credentials)
一般用于资源服务器是应用的一个后端模块,客户端向认证服务器验证身份来获取令牌。
curl -d "grant_type=client_credentials&client_id=clientId&client_secret=clientSecret" http://localhost:8080/oauth/token
{
"access_token" : "6c246e41-7d9a-4b69-b340-893635638ed8",
"token_type" : "bearer",
"expires_in" : 42444,
"scope" : "scope1"
}
- 密码式(password)
使用用户名/密码作为授权方式从授权服务器上获取令牌,一般不支持刷新令牌。
curl -d "grant_type=password&username=username&password=password&client_id=clientId&client_secret=clientSecret" http://localhost:8080/oauth/token
{
"access_token" : "b7ca73bc-992c-4c23-abbd-f819aa220199",
"token_type" : "bearer",
"refresh_token" : "51661039-fb67-4e6b-a563-bd4c58b084c0",
"expires_in" : 43199,
"scope" : "scope1"
}
- 隐藏式(implicit)
和授权码模式类似,只不过少了获取code的步骤,是直接获取令牌token的,适用于公开的浏览器单页应用,令牌直接从授权服务器返回,不支持刷新令牌,且没有code安全保证,令牌容易因为被拦截窃听而泄露。
1). 访问:
http://127.0.0.1:8080/oauth/authorize?client_id=clientId&response_type=token&scope=user&redirect_uri=http://www.baidu.com
2). 登录成功,回调地址携带access_token:
http://www.baidu.com/#access_token=551b968a-8e1f-4f4e-bc5e-e94544d982ec&token_type=bearer&expires_in=43199
- 授权码(authorization_code)
授权码模式(authorization_code)是功能最完整、流程最严密的授权模式,code保证了token的安全性,即使code被拦截,由于没有app_secret,也是无法通过code获得token的。
1). 访问:
http://127.0.0.1:8080/oauth/authorize?client_id=clientId&response_type=code&scope=scope1%20scope2&redirect_uri=http://www.baidu.com
2). 登录成功,回调地址携带code:
https://www.baidu.com/?code=s6htPB
3). 使用code换取access_token
4). 刷新tokencurl -d "grant_type=authorization_code&code=s6htPB&redirect_uri=http://www.baidu.com&client_id=clientId&client_secret=clientSecret" http://127.0.0.1:8080/oauth/token { "access_token": "551b968a-8e1f-4f4e-bc5e-e94544d982ec", "token_type": "bearer", "refresh_token": "248dff03-4124-4867-a3c9-7e299d2283c8", "expires_in": 42759, "scope":"scope1" }
curl -d "grant_type=refresh_token&refresh_token=248dff03-4124-4867-a3c9-7e299d2283c8&client_id=clientId&client_secret=clientSecret" http://127.0.0.1:8080/oauth/token { "access_token" : "39a2536a-d420-4798-abb3-1fc083cce887", "token_type" : "bearer", "refresh_token" : "7ba0dfcd-433e-4e5e-b06c-97c61bd54a9a", "expires_in" : 43199, "scope" : "scope1" }
- 访问测试
http://127.0.0.1:8080/api/group1?access_token=39a2536a-d420-4798-abb3-1fc083cce887
使用Redis存储AuthorizationCode
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new RandomValueAuthorizationCodeServices() {
private static final String AUTH_CODE_KEY = "auth_code";
@Override
protected void store(String code, OAuth2Authentication authentication) {
RedisConnection conn = getConnection();
try {
conn.hSet(AUTH_CODE_KEY.getBytes(StandardCharsets.UTF_8), code.getBytes(StandardCharsets.UTF_8),
SerializationUtils.serialize(authentication));
} catch (Exception e) {
log.error("save authentication code " + code + " failed:", e);
} finally {
conn.close();
}
}
@Override
protected OAuth2Authentication remove(String code) {
RedisConnection conn = getConnection();
try {
OAuth2Authentication authentication;
try {
byte[] bytes = conn.hGet(AUTH_CODE_KEY.getBytes(StandardCharsets.UTF_8), code.getBytes(StandardCharsets.UTF_8));
if(bytes == null) {
return null;
}
authentication = SerializationUtils.deserialize(bytes);
} catch (Exception e) {
return null;
}
if (null != authentication) {
conn.hDel(AUTH_CODE_KEY.getBytes(StandardCharsets.UTF_8), code.getBytes(StandardCharsets.UTF_8));
}
return authentication;
} catch (Exception e) {
log.error("remove authentication code " + code + "failed:", e);
return null;
} finally {
conn.close();
}
}
private RedisConnection getConnection() {
return redisConnectionFactory.getConnection();
}
};
}
自定义登录和授权页面
- pom.xml添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
- 创建Oauth2Controller
@Controller
@RequestMapping("/oauth")
public class Oauth2Controller {
//登录页面
@GetMapping("login")
public String login() {
return "oauth/login";
}
//授权页
@GetMapping("confirm_access")
public String authorizeGet() {
return "oauth/confirm_access";
}
//授权错误页
@GetMapping("error")
public String error() {
return "oauth/error";
}
}
- 在templates下创建页面模板
- login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form method="post" action="/oauth/login">
用户名:<input name="username" placeholder="请输入用户名" type="text">
密码:<input name="password" placeholder="请输入密码" type="password">
<input value="登录" type="submit">
</form>
</body>
</html>
- confirm_access.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>授权</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>
- error.html
${error}
网友评论