Spring OAuth2 资源服务器从数据库加载同步用户

Spring OAuth2 resource server load synchronized user from database

初始情况的简要说明:让我们假设一个Spring基于RESTfulAPI的引导作为OAuth2资源服务器。资源服务器使用 Spring Security 5 和常用方法配置。外部授权服务器将用户信息(例如电子邮件、名字、姓氏)传递为 JWT 声明,在客户端进行身份验证时收到。授权服务器持有的通用用户信息由资源服务器特定的用户信息(例如业务角色、域 UID)扩展。域用户由来自两个数据源的信息组成:

在资源服务器的数据库中创建从未通过JWT 在资源服务器上进行身份验证的新用户。资源服务器的数据库相应地为每个使用资源服务器API的用户包含一个用户实体,由授权服务器同步的信息和资源服务器业务逻辑补充的信息组成。

public class User {

    // Information synchronized from Authorization Server
    private String subject;
    private String preferredUsername;
    private String firstName;
    private String lastName;
    private String email;

    // Information added by Resource Server's business logic
    private UUID id;
    private String businessRole;
}

域用户的同步是通过侦听AuthenticationSuccessEvent 并在数据库中创建用户或在必要时更新用户来执行的。总而言之,对于由主题声明标识的每个 OAuth2 用户,数据库中都有一个域配置文件,其中包含域特定的附加信息。

这个article也清楚的描述了这个用户数据的同步和分发

现在开始实际问题:虽然 OAuth2 范围控制应用程序拥有哪些授权,但它也必须控制用户拥有哪些授权。用户授权是域特定的,并记录在资源服务器的数据库中。例如,用户应该只能删除他创建的评论。此类访问控制无法通过 OAuth2 范围进行控制。顺便说一句,我说的是 Spring 方法安全性 使用 @PreAuthorize@PostAuthorize.

@GetMapping
@PreAuthorize("...")
public void func(@AuthenticationPrincipal JWT jwt) {
}

例如,可以通过基于属性的访问控制 (ABAC) 来控制此类访问。但是,这假设当前 @AuthenticationPrincipal 不是 JWT,因为它是 Spring 资源服务器的标准,而是特定于域的 User 配置文件的实例。有没有办法将 JWT 转换为 User 配置文件,可能使用 UserDetailsService?

SpringSecurity 5 OAuth2 资源服务器是否有推荐的方法来从基于 JWT 的数据库加载信息,更准确地说是 User

我通常最终得到的是 JWT 转换器和创建自定义会话对象 (AbstractAuthenticationToken)

@Autowired
private final UserService userService;

@Override
public void configure(HttpSecurity http) {
    http
      .oauth2ResourceServer().jwt()
      .jwtAuthenticationConverter(new JwtConverter(userService))
      ... other security config ...

JWT 转换器:

public class JwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    private final UserService userService;

    public JwtConverter(UserService userService) {
        this.userManager = userService;
    }

    @Override
    public AbstractAuthenticationToken convert(@NotNull final Jwt jwt) {
        // Here is where I usually lazy-create or sync users instead of AuthenticationSuccessEvent
        User user = userService.getByEmail(jwt.getClaim("email"));
        Collection<? extends GrantedAuthority> authorities = translateAuthorities(jwt);
        return new CustomSession(user, jwt, authorities);
    }

    // Translate from your jwt as seen fit (I use a roles claim)
    private static Collection<? extends GrantedAuthority> translateAuthorities(final Jwt jwt) {
        Collection<String> userRoles = jwt.getClaimAsStringList("roles");
        if (userRoles != null)
            return userRoles
                    .stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                    .collect(Collectors.toSet());
        return Collections.emptySet();
    }
}

CustomSession 粗略示例:

public class CustomSession extends AbstractAuthenticationToken {
    final private User user;
    final private Jwt jwt;

    public CustomSession(User user, Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.user = user;
        this.jwt  = jwt;
        this.setAuthenticated(true);
    }

    @Override
    public Object getPrincipal() {
        return getUser();
    }

    @Override
    public Object getCredentials() {
        return getJwt();
    }

    public Jwt getJwt() {
        return jwt;
    }

    public User getUser() {
        return user;
    }
    

    //*********************************************************************
    //* Static Helpers
    //*********************************************************************
    public static Optional<CustomSession> GetSession() {
        return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .map(s -> s instanceof CustomSession ? (CustomSession) s : null);
    }

    public static Optional<Jwt> GetJwt() {
        return GetSession().map(CustomSession::getJwt);
    }

    public static Optional<User> GetUser() {
        return GetSession().map(CustomSession::getUser);
    }

    public static Optional<String> GetUserId() {
        return GetUser().map(User::getId);
    }

    public static Optional<String> GetUserEmail() {
        return GetUser().map(User::getEmail);
    }
}

在我的例子中,我在 userService.getByEmail() 后面使用了 JPA,所以需要注意的是用户对象是 'detached',所以你不应该使用这个对象进行用户更新。

另一个选项是扩展 BearerTokenAuthenticationToken 并提供构造函数来设置权限(而不是 CustomSession)。

====

吃完午饭回来,完全忘了提及权限检查。我介绍一个自定义权限评估器(一个简单的例子让你开始):

public class CustomPermissionEvaluator implements PermissionEvaluator {
    private SecurityService securityService;

    public CustomPermissionEvaluator(SecurityService securityService) {
        this.securityService = securityService;
    }

    @Override
    public boolean hasPermission(Authentication auth, Object resourceId, Object action) {
        if ((auth == null)
                || !(resourceId instanceof String) || StringUtils.isBlank((String) resourceId)
                || !(action instanceof String) || StringUtils.isBlank((String) action)){
            return false;
        }
        try {
            return securityService.hasAccess((String) resourceId, (String) action);
        } catch(Exception e){
            return false;
        }
    }

    @Override
    public boolean hasPermission(Authentication auth, Serializable resourceId, String component, Object action) {
        if ((auth == null)
                || !(resourceId instanceof String) || StringUtils.isBlank((String) resourceId)
                || StringUtils.isBlank(component)
                || !(action instanceof String) || StringUtils.isBlank((String) action)) {
            return false;
        }
        try {
            return securityService.hasAccess((String) resourceId, component, (String) action);
        } catch(Exception e){
            return false;
        }
    }
}

配置方式:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Autowired SecurityService securityService;

    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator(securityService));
        return expressionHandler;
    }
}

然后利用 @PreAuthorize("hasPersmission(...)"") 注释,例如:

// Can I Create {SubDomain} on resourceId
@PreAuthorize("hasPermission(#resourceId, 'SubDomain', 'CREATE')")
void example1(int resourceId) {...}    

// Can I Update resource
@PreAuthorize("hasPermission(#resourceId, 'UPDATE')")

可能不完全是您要找的,而是另一种(我认为是干净的)解决方案。