The New Old Thing

Quarkus Reactive Session 实现

· Cheng

Quarkus 安全架构

Quarkus 使用 HttpAuthenticationMechanism 接口作为保护 HTTP 应用的主要入口机制。

Quarkus Security 使用 HttpAuthenticationMechanism 从 HTTP 的请求中提取认证凭证并委托给 IdentityProvider 将凭证转化成 SecurityIdentity 。这些凭证的来源可以是 Authorization 头部,客户端的 HTTPS 证书或者是 Cookies。

IdentityProvider 会验证认证凭证并将其映射到 SecurityIdentity ,其中包含用户名、角色、原始认证凭证和其他属性。

对于每一个认证资源,可以注入一个 SecurityIdentity 实例来获得认证的身份信息。

HttpAuthenticationMechanism 的注册机制

// quarkus/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java 

public HttpAuthenticator(IdentityProviderManager identityProviderManager,
            Instance<PathMatchingHttpSecurityPolicy> pathMatchingPolicy,
            Instance<HttpAuthenticationMechanism> httpAuthenticationMechanism,
            Instance<IdentityProvider<?>> providers) {
        this.identityProviderManager = identityProviderManager;
        this.pathMatchingPolicy = pathMatchingPolicy;
        List<HttpAuthenticationMechanism> mechanisms = new ArrayList<>();
        for (HttpAuthenticationMechanism mechanism : httpAuthenticationMechanism) {
            boolean found = false;
            for (Class<? extends AuthenticationRequest> mechType : mechanism.getCredentialTypes()) {
                for (IdentityProvider<?> i : providers) {
                    if (i.getRequestType().equals(mechType)) {
                        found = true;
                        break;
                    }
                }
                if (found == true) {
                    break;
                }
            }
            // Add mechanism if there is a provider with matching credential type
            // If the mechanism has no credential types, just add it anyway
            if (found || mechanism.getCredentialTypes().isEmpty()) {
                mechanisms.add(mechanism);
            }
        }
        if (mechanisms.isEmpty()) {
            this.mechanisms = new HttpAuthenticationMechanism[] { new NoAuthenticationMechanism() };
        } else {
            mechanisms.sort(new Comparator<HttpAuthenticationMechanism>() {
                @Override
                public int compare(HttpAuthenticationMechanism mech1, HttpAuthenticationMechanism mech2) {
                    //descending order
                    return Integer.compare(mech2.getPriority(), mech1.getPriority());
                }
            });
            this.mechanisms = mechanisms.toArray(new HttpAuthenticationMechanism[mechanisms.size()]);
        }
    }

根据上边的 Quarkus 的源码可以看到 HttpAuthenticationMechanism 的注册流程为:

  1. 遍历所有已经植入的 HttpAuthenticationMechanism 实例
  2. 遍历单个 HttpAuthenticationMechanism 实例的所有支持的凭证类型
  3. 遍历所有已经植入的 IdentityProvider 并判断 HttpAuthenticationMechanism 支持的凭证中是与 IdentityProvider 中的请求类型对应
  4. 如果 IdentityProvider 中支持的请求类型与 HttpAuthenticationMechanism 中支持的凭证类型存在对应,或者 HttpAuthenticationMechanism 支持的凭证类型为空集合,那么此 HttpAuthenticationMechanism 均会被注册。

实现

根据上面的源码,要实现基于 Session 的认证,我们需要实现 HttpAuthenticationMechanismIdentityProviderAuthenticationRequest 接口。

由于 IdentityProvider 中支持的请求类型要与 HttpAuthenticationMechanism 中支持的凭证类型存在对应,因此首先需要实现一个 IdentityProvider 类。

import io.quarkus.security.identity.request.BaseAuthenticationRequest;

public class SessionAuthenticationRequest extends BaseAuthenticationRequest {
    private final String username;

    public SessionAuthenticationRequest(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }
}

在这里实现了一个 SessionAuthenticationRequest 类,并且添加了 username 属性。

import io.quarkus.security.ForbiddenException;
import io.quarkus.security.credential.Credential;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.mutiny.Uni;
import io.vertx.mutiny.sqlclient.Row;
import io.vertx.mutiny.sqlclient.Tuple;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.security.Permission;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@ApplicationScoped
public class SessionIdentityProvider implements IdentityProvider<SessionAuthenticationRequest> {
    @Inject
    io.vertx.mutiny.mysqlclient.MySQLPool client;

    @Override
    public Class<SessionAuthenticationRequest> getRequestType() {
        return SessionAuthenticationRequest.class;
    }

    @Override
    public Uni<SecurityIdentity> authenticate(SessionAuthenticationRequest sessionAuthenticationRequest, AuthenticationRequestContext authenticationRequestContext) {
        String username = sessionAuthenticationRequest.getUsername();

        Uni<Row> userUni = client
                .preparedQuery("SELECT username, nickname, email, password FROM user WHERE username = ?")
                .execute(Tuple.of(username))
                .onFailure()
                .transform(ForbiddenException::new)
                .onItem()
                .ifNotNull()
                .transformToUni(rows -> rows.toMulti().collect().asList().onItem().transform(records -> records.get(0)));

        Uni<List<String>> rolesUni = client
                .preparedQuery("SELECT role FROM user_role WHERE user = ?")
                .execute(Tuple.of(username))
                .onFailure()
                .transform(ForbiddenException::new)
                .onItem()
                .ifNotNull()
                .transformToUni(rows -> rows.toMulti().onItem().transform(role -> role.getString("role")).collect().asList());

        return Uni.combine().all().unis(userUni, rolesUni).asTuple().onItem().transform(tuple -> {
            Row user = tuple.getItem1();
            String name = user.getString("username");

            List<String> roles = tuple.getItem2();

            return new SecurityIdentity() {
                @Override
                public Principal getPrincipal() {
                    return new Principal() {
                        @Override
                        public String getName() {
                            return name;
                        }
                    };
                }

                @Override
                public boolean isAnonymous() {
                    return false;
                }

                @Override
                public Set<String> getRoles() {
                    return roles.stream().collect(Collectors.toSet());
                }

                @Override
                public boolean hasRole(String s) {
                    return roles.contains(s);
                }

                @Override
                public <T extends Credential> T getCredential(Class<T> aClass) {
                    return null;
                }

                @Override
                public Set<Credential> getCredentials() {
                    return null;
                }

                @Override
                public <T> T getAttribute(String s) {
                    return null;
                }

                @Override
                public Map<String, Object> getAttributes() {
                    return null;
                }

                @Override
                public Uni<Boolean> checkPermission(Permission permission) {
                    return null;
                }
            };
        });
    }
}
import io.netty.handler.codec.http.HttpResponseStatus;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.AuthenticationRequest;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.Collections;
import java.util.Set;

@ApplicationScoped
public class SessionAuthMechanism implements HttpAuthenticationMechanism {
    @Inject
    SessionCache cache;

    @Override
    public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
        return Uni.createFrom().item(context.request().getCookie("SESSION")).onItem().transformToUni(session -> {
            if (session == null || session.getValue().isEmpty()) {
                return Uni.createFrom().nullItem();
            }

            return cache.get(session.getValue()).flatMap((username) -> {
                if (username == null) {
                    return Uni.createFrom().failure(new AuthenticationFailedException());
                }

                return identityProviderManager.authenticate(new SessionAuthenticationRequest(username));
            });
        });
    }

    @Override
    public Uni<ChallengeData> getChallenge(RoutingContext context) {
        return Uni.createFrom().item(new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null));
    }

    @Override
    public Set<Class<? extends AuthenticationRequest>> getCredentialTypes() {
        return Collections.singleton(SessionAuthenticationRequest.class);
    }

    @Override
    public Uni<HttpCredentialTransport> getCredentialTransport(RoutingContext context) {
        return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.COOKIE, "SESSION"));
    }
}

SessionAuthMechanism::getCredentialTypes 方法中返回了包含SessionAuthenticationRequest 的集合,在 SessionIdentityProvider::getRequestType 函数中返回了 AuthenticationRequest 类,这样 SessionAuthMechanism 就会被注册了。

认证过程

当 HTTP 请求到达的时候,SessionAuthMechanism::authenticate 方法会被调用,在这个方法里边会从请求的上下文中获取 Session 对应的 Cookie。如果获取到了,则会创建 SessionAuthenticationRequest 类,并将 Session 的上下文保存到 SessionAuthenticationRequest 实例中,这里保存的是用户名。然后通过 identityProviderManager.authenticate 调用 SessionIdentityProviderauthenticate 函数。

SessionIdentityProviderauthenticate 函数中,通过数据库操作获取用户账户信息和角色信息,实例化 SecurityIdentity 并返回。

HttpAuthenticationMechanism::authenticate 根据其返回值或者异常有如下三种可能:

  1. 返回 nullItem 意味着跳过认证过程,此时用户处于未认证的状态,仍然可以访问不需要认证的路由。
  2. 抛出 AuthenticationFailedException 异常,意味着用户认证失败。访问任何的路由都会报错
  3. 返回 SecurityIdentity 的实例,意味着认证成功。后续的用户角色信息、权限信息将会从这个实例里边获取。

登录实现

import com.password4j.BcryptFunction;
import io.smallrye.mutiny.Uni;
import repository.UserRepository;

import javax.annotation.security.RolesAllowed;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.ws.rs.*;
import javax.ws.rs.core.*;
import java.util.UUID;


@ApplicationScoped
@Path("/api")
public class LoginResource {
    @Inject
    SessionCache sessionCache;

    @Inject
    UserRepository userRepository;

    @POST
    @Path("login")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public Uni<Response> login(User user) {
        return userRepository.findByName(user.username).onItem().transformToUni(u -> {
            if (u == null) {
                return Uni.createFrom().failure(new NotFoundException());
            }

            BcryptFunction crypt = BcryptFunction.getInstance(12);

            if (crypt.check(user.password, u.password)) {
                String session = UUID.randomUUID().toString();

                return sessionCache.set(session, user.username).flatMap((v) -> Uni.createFrom().item(Response.ok("ok").cookie(new NewCookie("SESSION", session)).build()));
            }

            return Uni.createFrom().item(Response.ok("error").build());
        });
    }

    @GET
    @Path("me")
    @RolesAllowed("user")
    public Uni<String> me(@Context SecurityContext context) {
        return Uni.createFrom().item(context.getUserPrincipal().getName());
    }

    public static class User {
        public String username;
        public String password;
    }
}

如果用户通过账户密码验证之后,生成 Session 并放置在 Cookie 中,传递给客户端。

参考

Security Tips and Tricks