Spring Authorization Server 授权码模式的例子 - 1期

2,031 阅读5分钟

背景:Spring Authorization Serve是spring security官方维护的授权服务器,目前版本是0.2.0。 本文是使用该授权服务器完成oauth2 authorization_code 模式的使用流程,包括:

  • 授权服务器(8800端口)
  • 资源服务器(api网关,2022端口)
  • 前端客户端

代码仓库:chintensakai/learn-spring-cloud (gitee.com)

流程演示:

image.png

(前端获取到token,可以处理回调事件)

image.png

1. 主要问题:

这是我开发过程遇到的一些问题和解决的办法,并不是所谓的最佳实践,有更好的办法欢迎指导

  1. 资源服务器和网关合到一个项目,网关是所有api的入口,在网关处就校验jwt是否具有api访问权限

  2. 前端肯定不能存储client_secret,所以只能由网关来拼接参数,去请求授权码

  3. 授权码附加在redirect_uri后面,获取这个授权码之后,要用这个码来获取token,如果设置成前端地址,那么输入用户名密码之后,返回授权码的过程中还会重定向一次,体验不好。 有例子设计成vue和网关之间增加一层nodejs server,用来接收重定向uri,拿到授权码再去请求token,我这里直接把这个功能放在网关,因为只有一个接口而已。

  4. 网关获取token之后,怎样传给前端?尝试用redirect到前端url,发现无法携带token,也没有办法放到响应体或者响应头返回。这里在网关增加了一个中间页,通过thymeleaf 将token写到中间页中,再使用window.opener.postMessage() 发送事件,这个方法是查了一些资料之后比较好的办法,还可以指定发送源以及接收源,是安全的。(观察了思否的第三方登录,也是会弹出一个中间页,所以目前先这样实现,是否有更好的办法)

  5. 前端通过 window.addEventListener() 就可以接收到token,然后存入localstorage就可以,接下来前端请求都可以携带这个token

  6. gateway里通过lb://xxxx来路由的话,弹出的窗口是实际的ip,而不是127.0.0.1,这样会报404。如果部署到不同的机器,应该没有这问题,因为我实测全部使用192.168.xxx,可以正常路由。

2. 流程介绍

  1. 授权服务器和资源服务器都注册到nacos
  2. 前端只放了一个登录按钮来模拟,点击该按钮,弹出登录窗口,在窗口中请求网关oauth2/authorize接口,网关将client_id、client_secret、redirect_uri等参数拼接,路由到认证服务器(8800端口)
  3. 登录窗口中输入用户名密码,认证服务器校验成功之后,重定向到redirect_uri,并在redirect_uri后面携带授权码,这里的redirect_uri重定向到网关的 /code-redirect ,在该接口中,构造请求去认证服务器请求 oauth2/token 获取token,认证服务器会将用户角色写入jwt。将token写入中间页,发送事件给给前端。
  4. 前端存储该token,并在请求中携带token,所有请求先到网关(资源服务器),网关解析jwt,获取用户的角色。授权服务器存储 用户-角色,资源服务器存储 角色 - 权限集,资源服务器解出token的角色,并根据method:url从权限集中获取该次请求所需要的角色,二者对比,完成鉴权。

3. 认证服务器关键代码

3.1 数据库数据

image.png 第一、三个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字段

image.png

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>