Skip to content

clerk-endpoint-security

使用Clerk和Spring Security做API鉴权

Clerk是一个用户管理SaaS服务,对于独立开发者来说可以快速的接入,省了很多时间成本。Clerk有基础的用户登录认证以及组织角色权限。利用Clerk这些点,可以集成做API的权限鉴权工作。

实现的功能

  • 基于用户令牌的会话认证
  • 用户请求API资源鉴权
    • 基于请求URL的方式
    • 基于方法安全注解的方式

整体思路

clerk-endpoint-security

  • 每个请求都会携带Clerk登录后的令牌,使用请求头传递Bearer token

  • 基于Spring Security的安全责任链逻辑,请求经FilterChain处理认证和鉴权。

    • AuthenticationFilter用于验证Clerk令牌是否是有效会话,认证成功,获取跟用户相关的所有组织角色权限码构造领域模型Authentication,存放用户有效凭证。
    • AuthorizationFilter用于验证token用户所有组织角色权限码和请求权限是否匹配。
    • SecurityContext存放当前上下文的用户Authentication认证信息。
  • 基于Spring Method Security的拦截器实现逻辑,使用SecurityContext存放的用户Authentication认证信息为安全注解(@PreAuthorize@PostAuthorize等)标注的方法进行权限验证。

一个约定

请求URL如何跟Clerk的组织角色权限挂钩?

  • Clerk的用户,组织,角色,权限的概念。用户可以拥有多个组织,每个组织下可以有多个角色,角色可以拥有多个权限。

  • 约定请求URL与权限码做一致性映射,例如:

    • /ai/chat <--> org:ai:chat
    • /default/chat <--> org:default:chat
    • /chat <--> org:default:chat
    • /ai/chat/gpt <--> org:ai:chat_gpt

    clerk-endpoint-security

具体实现

如何验证Clerk令牌的有效性?

  • Clerk提供的Java beckend API,用于验证用户令牌有效性和获取用户的组织角色权限。
java
package com.juliusyolo.service.impl;

import com.clerk.backend_api.Clerk;
import com.clerk.backend_api.models.components.Client;
import com.clerk.backend_api.models.components.Session;
import com.clerk.backend_api.models.components.Status;
import com.clerk.backend_api.models.operations.GetUserResponse;
import com.clerk.backend_api.models.operations.UsersGetOrganizationMembershipsResponse;
import com.clerk.backend_api.models.operations.VerifyClientRequestBody;
import com.clerk.backend_api.models.operations.VerifyClientResponse;
import com.juliusyolo.component.ClerkProperties;
import com.juliusyolo.component.EndpointProperties;
import com.juliusyolo.exception.UserAuthenticationException;
import com.juliusyolo.model.UserModel;
import com.juliusyolo.model.UserPermissionAuthenticationToken;
import com.juliusyolo.service.ClerkService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.util.CollectionUtils;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

/**
 * <p>
 * a clerk implement
 * </p>
 *
 * @author julius.yolo
 * @version : ClerkService v0.1
 */
public class ClerkServiceImpl implements ClerkService {

    private final static Logger LOGGER =
            LoggerFactory.getLogger(ClerkServiceImpl.class);

    private final Clerk clerk;

    private final ClerkProperties clerkProperties;

    private final EndpointProperties endpointProperties;

    public ClerkServiceImpl(Clerk clerk, ClerkProperties clerkProperties, EndpointProperties endpointProperties) {
        this.clerk = clerk;
        this.clerkProperties = clerkProperties;
        this.endpointProperties = endpointProperties;
    }

    @Override
    public String getActiveClerkUserId(String token) {
        LOGGER.info("ClerkServiceImpl#getActiveClerkUserId token:{}", token);
        try {
            VerifyClientRequestBody req = VerifyClientRequestBody.builder().token(token).build();
            VerifyClientResponse res = clerk.clients().verify().request(req).call();
            if (res.client().isPresent()) {
                Client client = res.client().get();
                if (CollectionUtils.isEmpty(client.sessions())) {
                    return null;
                }
                List<Session> sessions = client.sessions().stream().filter(session -> Objects.equals(session.status(), Status.ACTIVE)).toList();
                if (CollectionUtils.isEmpty(sessions)) {
                    return null;
                }
                return sessions.getFirst().userId();
            }
            return null;
        } catch (Exception e) {
            throw new UserAuthenticationException("No client found", e);
        }
    }

    @Override
    public UserModel verifyToken(UserPermissionAuthenticationToken token) {
        String userId = this.getActiveClerkUserId(token.getToken());
        if (userId == null) {
            throw new UserAuthenticationException("No active clerk user found");
        }
        try {
            UserModel userModel = null;
            GetUserResponse userRes = clerk.users().get()
                    .userId(userId)
                    .call();

            if (userRes.user().isPresent()) {
                userModel = new UserModel();
                userModel.setUser(userRes.user().get());
                UsersGetOrganizationMembershipsResponse membershipsRes = clerk.users().getOrganizationMemberships()
                        .userId(userId)
                        .limit(clerkProperties.maxOrganizationCount())
                        .offset(0L)
                        .call();

                userModel.setOrganizationMemberships(membershipsRes.organizationMemberships());
            }
            return userModel;
        } catch (Exception e) {
            throw new UserAuthenticationException("Fetch user failed", e);
        }

    }

    @Override
    public boolean verifyAuthorization(UserPermissionAuthenticationToken token, String path) {
        if (!endpointProperties.pathAuthorizationEnable()) {
            return true;
        }
        if (path.startsWith(endpointProperties.prefix())) {
            path = path.substring(endpointProperties.prefix().length());
        }
        if (path.startsWith("/")) {
            path = path.substring(1);
        }
        String[] split = path.split("/");
        if (split.length >= 2) {
            path = "org:" + split[0] + ":" + Arrays.stream(split).skip(1).collect(Collectors.joining("_"));
        } else {
            path = "org:default:" + split[0];
        }
        return token.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch(path::equals);
    }
}

令牌如何与AuthenticationFilter结合?

  • AuthenticationFilter使用AuthenticationManager做认证。
java
package com.juliusyolo.component;

import com.juliusyolo.exception.UserAuthenticationException;
import com.juliusyolo.model.UserModel;
import com.juliusyolo.model.UserPermissionAuthenticationToken;
import com.juliusyolo.service.UserService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

import java.util.Objects;

/**
 * <p>
 * UserAuthenticationManager
 * </p>
 *
 * @author julius.yolo
 * @version : UserAuthenticationManager v0.1
 */
public class UserAuthenticationManager implements AuthenticationManager {


    private final UserService userService;

    public UserAuthenticationManager(UserService userService) {
        this.userService = userService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UserPermissionAuthenticationToken userPermissionAuthenticationToken =
                (UserPermissionAuthenticationToken) authentication;
        UserModel userModel = userService.verifyToken(userPermissionAuthenticationToken);
        if (Objects.isNull(userModel)) {
            throw new UserAuthenticationException("No user found");
        }
        return UserPermissionAuthenticationToken.authenticated(userModel, userModel.getAuthorities());
    }
}

URL权限如何与AuthorizationFilter结合?

  • AuthorizationFilter使用AuthorizationManager做权限决策。
java
public class UserAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    private final UserService userService;

    public UserAuthorizationManager(UserService userService) {
        this.userService = userService;
    }

    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
        String path = context.getRequest().getServletPath();
        UserPermissionAuthenticationToken token = (UserPermissionAuthenticationToken) authentication.get();
        boolean decision = userService.verifyAuthorization(token, path);
        return new AuthorizationDecision(decision);
    }
}

方法安全注解如何做权限校验?

  • Spring Security方法安全提供了@PreAuthorize@PostAuthorize@PreFilter@PostFilter@Secured方法安全相关的注解,以及对JSR250中关于安全注解@RolesAllowed@DenyAll@PermitAll的支持。
  • 每种注解都由一个对应方法拦截器,基于Spring AOP实现原理,具体使用对应的AuthorizationManager\<MethodInvocation>,从Spring Security的安全上线文SecurityContext中获取对应的Authentication进行权限判断。
  • 方法安全注解使用方式:
java
@Service
public class ChatService {

    @PreAuthorize("hasAuthority('org:ai:chat')")
    public String chat() {
        return "hello,world!";
    }
}

最后成果

Maven依赖

封装成springboot starter,利用Spring的自动装配能力,自动为容器注入所需要的组件。添加以下依赖,无需多余的代码就可以使用ClerkAPI的鉴权,同时支持SerlvetReactive两种Web。

xml
<dependency>
    <groupId>com.juliusyolo</groupId>
    <artifactId>endpoint-security-clerk-spring-boot-starter</artifactId>
    <version>1.0.2-RELEASE</version>
</dependency>

配置说明

properties
yolo.clerk.max-organization-count=10  #指定clerk用户最多拥有组织数
yolo.clerk.secret-key=${CLERK_SECRET_KEY} #Clerk应用的秘钥
yolo.endpoint.prefix=/api/v1 #端点前缀
yolo.endpoint.permit-paths=/favicon.ico #允许的请求路径
yolo.endpoint.authorization-paths=/api/** #需要认证鉴权的请求路径
yolo.endpoint.path-authorization-enable=false #是否开启请求路径鉴权

Github地址

最后更新于: