背景:Spring Authorization Serve是spring security官方维护的授权服务器,目前版本是0.2.0。 本文是使用该授权服务器完成oauth2 authorization_code 模式的使用流程,包括:
- 授权服务器(8800端口)
- 资源服务器(api网关,2022端口)
- 前端客户端
代码仓库:chintensakai/learn-spring-cloud (gitee.com)
流程演示:
(前端获取到token,可以处理回调事件)
1. 主要问题:
这是我开发过程遇到的一些问题和解决的办法,并不是所谓的最佳实践,有更好的办法欢迎指导
-
资源服务器和网关合到一个项目,网关是所有api的入口,在网关处就校验jwt是否具有api访问权限
-
前端肯定不能存储client_secret,所以只能由网关来拼接参数,去请求授权码
-
授权码附加在redirect_uri后面,获取这个授权码之后,要用这个码来获取token,如果设置成前端地址,那么输入用户名密码之后,返回授权码的过程中还会重定向一次,体验不好。 有例子设计成vue和网关之间增加一层nodejs server,用来接收重定向uri,拿到授权码再去请求token,我这里直接把这个功能放在网关,因为只有一个接口而已。
-
网关获取token之后,怎样传给前端?尝试用redirect到前端url,发现无法携带token,也没有办法放到响应体或者响应头返回。这里在网关增加了一个中间页,通过thymeleaf 将token写到中间页中,再使用window.opener.postMessage() 发送事件,这个方法是查了一些资料之后比较好的办法,还可以指定发送源以及接收源,是安全的。(观察了思否的第三方登录,也是会弹出一个中间页,所以目前先这样实现,是否有更好的办法)
-
前端通过 window.addEventListener() 就可以接收到token,然后存入localstorage就可以,接下来前端请求都可以携带这个token
-
gateway里通过lb://xxxx来路由的话,弹出的窗口是实际的ip,而不是127.0.0.1,这样会报404。如果部署到不同的机器,应该没有这问题,因为我实测全部使用192.168.xxx,可以正常路由。
2. 流程介绍
- 授权服务器和资源服务器都注册到nacos
- 前端只放了一个登录按钮来模拟,点击该按钮,弹出登录窗口,在窗口中请求网关oauth2/authorize接口,网关将client_id、client_secret、redirect_uri等参数拼接,路由到认证服务器(8800端口)
- 登录窗口中输入用户名密码,认证服务器校验成功之后,重定向到redirect_uri,并在redirect_uri后面携带授权码,这里的redirect_uri重定向到网关的 /code-redirect ,在该接口中,构造请求去认证服务器请求 oauth2/token 获取token,认证服务器会将用户角色写入jwt。将token写入中间页,发送事件给给前端。
- 前端存储该token,并在请求中携带token,所有请求先到网关(资源服务器),网关解析jwt,获取用户的角色。授权服务器存储 用户-角色,资源服务器存储 角色 - 权限集,资源服务器解出token的角色,并根据method:url从权限集中获取该次请求所需要的角色,二者对比,完成鉴权。
3. 认证服务器关键代码
3.1 数据库数据
第一、三个schema是spring-authorization-server提供,第二个是RBAC授权模型常规的五张表。user-role-permission,以及两两之间的关联表。
3.1 自定义登录页
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.authorizeRequests(authorizeRequests ->
authorizeRequests.antMatchers("/login", "/assets/**").permitAll().anyRequest()
.authenticated()
)
.formLogin(form -> form.loginPage("/login"));
return http.build();
}
3.2 从数据库认证用户信息:
@Component
@Slf4j
public class LoginUserDetailsService implements UserDetailsService {
@Autowired
private IUserService userService;
@Autowired
private IRoleService roleService;
@Autowired
private IUserRoleService userRoleService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查出userId
User userByName = userService.getUserByName(username);
log.error("===================== userByName {}", userByName);
// 根据userId 查找对应的角色
List<String> roleIds = userRoleService.getRoleIdsByUserId(userByName.getUserId());
log.error("===================== roleIds {}", roleIds);
// 查找所有的角色名
List<String> roles = roleService.getRolesByIds(roleIds);
log.error("===================== roles {}", roles);
AuthUser authUser = new AuthUser();
authUser.setUsername(username);
authUser.setPassword(userByName.getPassword());
authUser.setAuthorities(roles.stream()
.map(SimpleGrantedAuthority::new).collect(Collectors.toList()));
log.error("===================== authUser {}", authUser);
return authUser;
}
}
3.3 jwt中携带用户角色信息:
class CustomOAuth2TokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
@Override
public void customize(JwtEncodingContext jwtEncodingContext) {
Authentication authentication = jwtEncodingContext.getPrincipal();
AuthUser principal = (AuthUser) authentication.getPrincipal();
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) principal.getAuthorities();
jwtEncodingContext.getClaims().claim("roles", authorities.stream().map(SimpleGrantedAuthority::getAuthority)
.collect(Collectors.toList()));
jwtEncodingContext.getClaims().claim("un", principal.getUsername());
jwtEncodingContext.getHeaders()
.header("client-id", jwtEncodingContext.getRegisteredClient().getClientId());
}
}
4. 网关(资源服务器)关键代码
4.1 yaml gateway配置
spring:
application:
name: learn-gateway
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/learn-auth?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
gateway:
routes:
- id: gateway-oauth2
uri: lb://learn-auth
predicates:
- Path=/oauth2/authorize/**
filters:
- AddRequestParameter=client_id, my-client
- AddRequestParameter=client_secret, my-secret
- AddRequestParameter=response_type, code
- AddRequestParameter=redirect_uri, http://127.0.0.1:2022/code-redirect
security:
oauth2:
resourceserver:
jwt:
# 从授权服务获取公钥,用以解析jwt
jwk-set-uri: http://127.0.0.1:8800/oauth2/jwks
4.2 放开一部分接口,以及配置jwt解析规则,因为授权服务器的角色信息是写到了jwt中的 roles字段
4.3 解析token,鉴权。这里举了两个例子,真实情况可以启动时从数据库读取角色和权限集列表,存到redis
@Component
@Slf4j
public class ResourceServerManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private static final Map<String, String> AUTH_MAP = Maps.newConcurrentMap();
@PostConstruct
public void initAuthMap() {
AUTH_MAP.put("GET:/api/userInfo", "ROLE_ADMIN, ROLE_USER");
AUTH_MAP.put("GET:/api/hello", "GUEST");
}
@Override
public Mono<AuthorizationDecision> check(
Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
String path = request.getURI().getPath();
HttpMethod method = request.getMethod();
String pathToAuthor = method.name() + ":" + path;
if (!AUTH_MAP.containsKey(pathToAuthor)) {
return Mono.just(new AuthorizationDecision(false));
}
return mono.filter(Authentication::isAuthenticated)
.map(auth -> {
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
for (GrantedAuthority grantedAuthority : authorities) {
if (AUTH_MAP.get(pathToAuthor).contains(grantedAuthority.getAuthority())) {
return new AuthorizationDecision(true);
}
}
return new AuthorizationDecision(false);
}).defaultIfEmpty(new AuthorizationDecision(false));
}
}
4.4 重定向接口以及中间页
@Slf4j
@Controller
@RequestMapping()
public class HelloController {
@Autowired
private RestTemplate restTemplate;
@GetMapping("/code-redirect")
public String helloCodeRedirect(Model model, @RequestParam String code) {
log.error("========================code {} ", code);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// todo 参数硬编码
headers.add("Authorization", "Basic bXktY2xpZW50Om15LXNlY3JldA==");
MultiValueMap<String, Object> para = new LinkedMultiValueMap<>();
para.add("code", code);
para.add("grant_type", "authorization_code");
para.add("redirect_uri", "http://127.0.0.1:2022/code-redirect");
HttpEntity<MultiValueMap> requestEntity = new HttpEntity<MultiValueMap>(para, headers);
log.error("---------------------------------para -{} ", para);
ResponseEntity<String> resp = restTemplate.exchange ("http://localhost:8800/oauth2/token",
HttpMethod.POST,
requestEntity,
String.class);
log.error("----------------------------------");
log.error("token {} ", resp.getStatusCode());
log.error("token {} ", resp.getBody());
model.addAttribute("token", resp.getBody());
return "loading";
}
}
<body>
登录中...
[[${token}]]
<script th:inline="javascript">
window.onload = function() {
window.opener.postMessage([[${token}]], "http://127.0.0.1:8080");
window.close();
}
</script>
</body>
5. 前端按钮
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<el-button type="primary" @click="goLogin">登录</el-button>
</div>
</template>
<script>
export default {
name: "HelloWorld",
props: {
msg: String,
},
methods: {
goLogin() {
const loginUrl = "http://127.0.0.1:2022/oauth2/authorize";
window.open(
loginUrl,
"newwindow",
"height=500, width=500, top=0, left=0, toolbar=no, scrollbar=no, status=no"
);
window.addEventListener("message", (event) => {
console.log(event.data);
});
},
},
};
</script>