使用Clerk和Spring Security做API鉴权
Clerk
是一个用户管理SaaS
服务,对于独立开发者来说可以快速的接入,省了很多时间成本。Clerk
有基础的用户登录认证以及组织角色权限。利用Clerk
这些点,可以集成做API
的权限鉴权工作。
实现的功能
- 基于用户令牌的会话认证
- 用户请求API资源鉴权
- 基于请求URL的方式
- 基于方法安全注解的方式
整体思路
每个请求都会携带
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
令牌的有效性?
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
的安全上线文SecurityContex
t中获取对应的Authentication
进行权限判断。 - 方法安全注解使用方式:
java
@Service
public class ChatService {
@PreAuthorize("hasAuthority('org:ai:chat')")
public String chat() {
return "hello,world!";
}
}
最后成果
Maven依赖
封装成springboot starter,利用Spring的自动装配能力,自动为容器注入所需要的组件。添加以下依赖,无需多余的代码就可以使用Clerk
做API
的鉴权,同时支持Serlvet
和Reactive
两种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 #是否开启请求路径鉴权