云中的Jakarta安全性和REST第3部分:了解OAuth2


安全通常是在谈论软件体系结构时遗漏的主题,但这并不意味着它不是必需的。为了进一步讨论它,我们使用Jakarta EE创建了有关Java API安全性的系列文章。在第三部分中,我们将讨论OAuth2身份验证过程,将其快速移动到云中以及如何使用两个MongoDB和Redis数据库实现它。

OAuth 2.0是一种协议,该协议允许权限在系统或站点之间访问授权资源,从而更好地封装了诸如用户名和密码之类的关键信息。OAuth 2.0概述:

第一步是授权请求。它标识用户的真实性 获得授权后,下一步就是请求令牌 使用此访问令牌,所有请求都将来自该访问令牌 这是一个简短的摘要,但是如果您想了解有关OAuth 2.0的更多信息,那么值得看一下该规范。

这种方法的显着优点之一是低密码暴露,除了能够为同一用户生成大量令牌之外。可以为每个设备创建令牌,并且如果您想撤消对特定设备的访问,则只需从该设备中删除令牌即可。另一个关键点是,这些设备都无法访问用户的登录名和密码,而只能访问令牌。考虑到封装,暴露的密码越少,安全性越好。

另一方面,我们大大增加了体系结构的复杂性。毕竟,在这还只是密码之前,现在,每个用户都管理着大量的令牌。

在架构方面,OAuth2将遵循以下步骤:

  1. 遵循规则的第一步是请求获取新令牌
  2. 该请求将产生一对令牌:access_token和refresh_token
  3. 从那时起,每个请求都将使用“ access_token”发出
  4. access_token会在某个时候过期,使其失效
  5. 由于refresh_token,下一步将是对其进行更新,以创建一个新令牌。我们将发出一个新请求,但是这次使用refresh_token,它将返回步骤2中已经提到的结果。
  6. 该循环将再次使用access_token继续,直到到期为止

image2.png 此周期仅适用于一台设备-如果需要另一台设备访问另一台计算机,则每个设备都有其周期以及其各自的令牌。

可以在逻辑上和物理上(即在另一台服务器上)从用户信息中分离生成令牌的逻辑。但是在我们的示例中,我们将通过演示逻辑上但不是物理上的分离来简化。身份验证机制逻辑将被创建为安全API的子域,因为存在对用户进行身份验证的依赖性。由于它们具有TTL,因此我们将使用Redis。

image3.png

通常,在讨论代码时,我们将充分利用本文第二部分中的所有存储和用户管理逻辑,不同之处在于机制会有所不同。我们将添加其他一些用于处理Redis令牌生成的问题。

<dependency>
    <groupId>org.eclipse.jnosql.artemis</groupId>
    <artifactId>artemis-key-value</artifactId>
    <version>${jnosql.version}</version>
</dependency>
<dependency>
    <groupId>org.eclipse.jnosql.diana</groupId>
    <artifactId>redis-driver</artifactId>
    <version>${jnosql.version}</version>
</dependency>

关于令牌的建模和持久性,将创建三个新实体。重要的一点是,封装规则仍然是相关的。仅出于这个原因,在我们别无选择的情况下,我们正在使用私有可见性和公共访问方法。除了作为安全性的良好实践之外,Effective Java还要讲很多这是一个好规则。关键点是,除了Jakarta NoSQL表示法之外,这些实体还使用JSONB表示法。原因是Redis将信息存储为文本,而Redis驱动程序使用的存储策略是JSON,使用的是Jakarta世界中的某种实现。

@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class UserToken {

    @Id
    @JsonbProperty
    private String username;

    @Column
    @JsonbProperty
    private Set<Token> tokens;
 //...
}

@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class RefreshToken {
    @Id
    @JsonbProperty
    private String id;

    @JsonbProperty
    private String token;

    @JsonbProperty
    private String accessToken;
    @JsonbProperty
    private String user;

    //...
}

@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class AccessToken {

    @Id
    private String id;
    @JsonbProperty
    private String user;
    @JsonbProperty
    private String token;

    //...
}

遵循生成和更新这些令牌的规则,我们有OAuth2Service类,它将管理存储机制的所有逻辑。数据验证将通过Bean验证执行,并且从上下文开始,为了创建和更新令牌,我们将为每个设置创建一个组。因此,就像在MongoDB中一样,值键API可以使用存储库。但是,由于操作非常简单,因此将使用KeyValueTemplate。重要的一点是当刷新令牌被保留时,它具有定义TTL的第二个参数。这意味着在特定时间后,信息将自动从Redis中删除。

import jakarta.nosql.mapping.keyvalue.KeyValueTemplate;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import sh.platform.sample.security.SecurityService;
import sh.platform.sample.security.User;
import sh.platform.sample.security.UserNotAuthorizedException;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Validator;
import java.time.Duration;
import java.util.Arrays;
import java.util.Set;

@ApplicationScoped
class Oauth2Service {

    static final int EXPIRE_IN = 3600;

    static final Duration EXPIRES = Duration.ofSeconds(EXPIRE_IN);

    @Inject
    private SecurityService securityService;

    @Inject
    @ConfigProperty(name = "keyvalue")
    private KeyValueTemplate template;

    @Inject
    private Validator validator;

    public Oauth2Response token(Oauth2Request request) {

        final Set<ConstraintViolation<Oauth2Request>> violations = validator.validate(request, Oauth2Request
                .GenerateToken.class);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }

        final User user = securityService.findBy(request.getUsername(), request.getPassword());
        final UserToken userToken = template.get(request.getUsername(), UserToken.class)
                .orElse(new UserToken(user.getName()));

        final Token token = Token.generate();

        AccessToken accessToken = new AccessToken(token, user.getName());

        RefreshToken refreshToken = new RefreshToken(userToken, token, user.getName());

        template.put(refreshToken, EXPIRES);
        template.put(Arrays.asList(userToken, accessToken));

        final Oauth2Response response = Oauth2Response.of(accessToken, refreshToken, EXPIRE_IN);
        return response;
    }

    public Oauth2Response refreshToken(Oauth2Request request) {
        final Set<ConstraintViolation<Oauth2Request>> violations = validator.validate(request, Oauth2Request
                .RefreshToken.class);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }

        RefreshToken refreshToken = template.get(RefreshToken.PREFIX + request.getRefreshToken(), RefreshToken.class)
                .orElseThrow(() -> new UserNotAuthorizedException("Invalid Token"));

        final UserToken userToken = template.get(refreshToken.getUser(), UserToken.class)
                .orElse(new UserToken(refreshToken.getUser()));

        final Token token = Token.generate();
        AccessToken accessToken = new AccessToken(token, refreshToken.getUser());
        refreshToken.update(accessToken, userToken, template);
        template.put(accessToken, EXPIRES);
        final Oauth2Response response = Oauth2Response.of(accessToken, refreshToken, EXPIRE_IN);
        return response;
    }

}

import sh.platform.sample.security.infra.FieldPropertyVisibilityStrategy;

import javax.json.bind.annotation.JsonbVisibility;
import javax.validation.constraints.NotBlank;
import javax.ws.rs.FormParam;

@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class Oauth2Request {

    @FormParam("grand_type")
    @NotBlank
    private String grandType;

    @FormParam("username")
    @NotBlank(groups = {GenerateToken.class})
    private String username;

    @FormParam("password")
    @NotBlank(groups = {GenerateToken.class})
    private String password;

    @FormParam("refresh_token")
    @NotBlank(groups = {RefreshToken.class})
    private String refreshToken;

    public void setGrandType(GrantType grandType) {
        if(grandType != null) {
            this.grandType = grandType.get();
        }
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public GrantType getGrandType() {
        if(grandType != null) {
            return GrantType.parse(grandType);
        }
        return null;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }
    public String getRefreshToken() {

        return refreshToken;

    }

    public @interface  GenerateToken{}

    public @interface  RefreshToken{}
}

在OAuth2功能内,您可以看到我们有一个方法和两个操作。这是代码中getGrantType方法返回带有两个选项的枚举的原因之一。

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.BeanParam;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@ApplicationScoped
@Path("oauth2")
public class Oauth2Resource {

    @Inject
    private Oauth2Service service;

    @POST
    @Path("token")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Produces(MediaType.APPLICATION_JSON)
    public Oauth2Response token(@BeanParam @Valid Oauth2Request request) {
        switch (request.getGrandType()) {
            case PASSWORD:
                return service.token(request);
            case REFRESH_TOKEN:
                return service.refreshToken(request);
            default:
                throw new UnsupportedOperationException("There is not support to another type");
        }
    }
}

OAuth2Authentication类接收请求,并搜索使用正则表达式进行验证的“ Authorization”标头。标头获得批准后,下一步就是检查数据库中此令牌的存在,在这种情况下,我们将使用Redis。验证刷新令牌后,我们会将用户ID发送到IdentityStoreHandler表示的存储机制。

与第二部分相比,标识类需要一些更改。它会继续加载用户信息和权限规则,但是,没有密码验证,因为所有这些操作均由用户的标识符完成。

import jakarta.nosql.mapping.keyvalue.KeyValueTemplate;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.security.enterprise.AuthenticationStatus;
import javax.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism;
import javax.security.enterprise.authentication.mechanism.http.HttpMessageContext;
import javax.security.enterprise.credential.CallerOnlyCredential;
import javax.security.enterprise.identitystore.CredentialValidationResult;
import javax.security.enterprise.identitystore.IdentityStoreHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static javax.security.enterprise.identitystore.CredentialValidationResult.Status.VALID;

@ApplicationScoped
public class Oauth2Authentication implements HttpAuthenticationMechanism {

    private static final Pattern CHALLENGE_PATTERN

            = Pattern.compile("^Bearer *([^ ]+) *$", Pattern.CASE_INSENSITIVE);

    @Inject

    private IdentityStoreHandler identityStoreHandler;

    @Inject

    @ConfigProperty(name = "keyvalue")

    private KeyValueTemplate template;

    @Override
    public AuthenticationStatus validateRequest(HttpServletRequest request, HttpServletResponse response,
                                                HttpMessageContext httpMessageContext) {

        final String authorization = request.getHeader("Authorization");

        Matcher matcher = CHALLENGE_PATTERN.matcher(Optional.ofNullable(authorization).orElse(""));
        if (!matcher.matches()) {
            return httpMessageContext.doNothing();
        }
        final String token = matcher.group(1);
        final Optional<AccessToken> optional = template.get(AccessToken.PREFIX + token, AccessToken.class);

        if (!optional.isPresent()) {
            return httpMessageContext.responseUnauthorized();
        }
        final AccessToken accessToken = optional.get();
        final CredentialValidationResult validate = identityStoreHandler.validate(new CallerOnlyCredential(accessToken.getUser()));
        if (validate.getStatus() == VALID) {
            return httpMessageContext.notifyContainerAboutLogin(validate.getCallerPrincipal(), validate.getCallerGroups());
        } else {
            return httpMessageContext.responseUnauthorized();
        }
    }
}

关键点在于,尽管身份验证机制取决于数据库,但是由于安全性API的缘故,它们还是未知的。遵循域和子域规则,允许OAuth2子域查看安全API。但是,由于多种原因,不允许出现相反的情况。这种周期性的依赖关系带来了一些问题,例如,如果我们想在某个时候将逻辑转移到服务器上。这将更加困难,因为很难以这种方式维护软件。思考周期性成瘾的一种简单方法是反思经典的鸡和鸡蛋问题。

但是,这是一个问题:删除用户后,也必须删除相应的令牌,这一点也很重要。建立这种分离的一种方法是通过事件。当我们使用CDI时,它将触发事件。

import jakarta.nosql.mapping.keyvalue.KeyValueTemplate;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import sh.platform.sample.security.RemoveToken;
import sh.platform.sample.security.RemoveUser;
import sh.platform.sample.security.User;
import sh.platform.sample.security.UserForbiddenException;

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

@ApplicationScoped
class Oauth2Observes {

    @Inject
    @ConfigProperty(name = "keyvalue")
    private KeyValueTemplate template;

    public void observe(@Observes RemoveUser removeUser) {

     //....

    }

    public void observe(@Observes RemoveToken removeToken) {
     //....

    }
}

迁移到Cloud 在我们的系列文章中,我们使用PaaS来简化硬件的复杂性和容器之间访问的安全性。毕竟,如果我们对软件进行严格控制,那将是徒劳的,并且数据库具有整个互联网的公共IP。因此,我们将维持使用Platform.sh的策略。

我们只会提到更改服务文件以添加另一个数据库(在本例中为Redis):

mongodb:
  type: mongodb:3.6
  disk: 1024
redis:
  type: redis-persistent:5.0
  disk: 1024

有必要在应用程序配置文件中进行修改。目的是提供凭据,以便应用程序容器可以访问数据库容器。

name: app
type: "java:11"
disk: 1024
hooks:
    build:  mvn clean package payara-micro:bundle
relationships:
    mongodb: 'mongodb:mongodb'
    redis: 'redis:redis'

web:
    commands:
        start: |
            export MONGO_PORT=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].port"`
            export MONGO_HOST=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].host"`
            export MONGO_ADDRESS="${MONGO_HOST}:${MONGO_PORT}"
            export MONGO_PASSWORD=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].password"`
            export MONGO_USER=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].username"`
            export MONGO_DATABASE=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].path"`
            export REDIS_HOST=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".redis[0].host"`
            java -jar -Xmx$(jq .info.limits.memory /run/config.json)m -XX:+ExitOnOutOfMemoryError \
            -Ddocument.settings.jakarta.nosql.host=$MONGO_ADDRESS \
            -Ddocument.database=$MONGO_DATABASE -Ddocument.settings.jakarta.nosql.user=$MONGO_USER \
            -Ddocument.settings.jakarta.nosql.password=$MONGO_PASSWORD \
            -Ddocument.settings.mongodb.authentication.source=$MONGO_DATABASE \
            -Dkeyvalue.settings.jakarta.nosql.host=$REDIS_HOST \
            target/microprofile-microbundle.jar --port $PORT

这样,我们就可以使用Jakarta Security实用地讨论OAuth2机制的概念和设计。关键点是,对于每个请求,我们都需要在数据库中执行搜索,因为令牌不过是指向信息的指针;但是,这不是信息本身。有一种令人兴奋的方式来使用符号来放置信息,例如,通过将令牌与JWT结合使用。OAuth2和JWT的这种结合将在下一章中介绍。


原文链接:http://codingdict.com