安全通常是在谈论软件体系结构时遗漏的主题,但这并不意味着它不是必需的。为了进一步讨论它,我们使用Jakarta EE创建了有关Java API安全性的系列文章。在第三部分中,我们将讨论OAuth2身份验证过程,将其快速移动到云中以及如何使用两个MongoDB和Redis数据库实现它。
OAuth 2.0是一种协议,该协议允许权限在系统或站点之间访问授权资源,从而更好地封装了诸如用户名和密码之类的关键信息。OAuth 2.0概述:
第一步是授权请求。它标识用户的真实性 获得授权后,下一步就是请求令牌 使用此访问令牌,所有请求都将来自该访问令牌 这是一个简短的摘要,但是如果您想了解有关OAuth 2.0的更多信息,那么值得看一下该规范。
这种方法的显着优点之一是低密码暴露,除了能够为同一用户生成大量令牌之外。可以为每个设备创建令牌,并且如果您想撤消对特定设备的访问,则只需从该设备中删除令牌即可。另一个关键点是,这些设备都无法访问用户的登录名和密码,而只能访问令牌。考虑到封装,暴露的密码越少,安全性越好。
另一方面,我们大大增加了体系结构的复杂性。毕竟,在这还只是密码之前,现在,每个用户都管理着大量的令牌。
在架构方面,OAuth2将遵循以下步骤:
access_token和refresh_token
access_token
refresh_token
此周期仅适用于一台设备-如果需要另一台设备访问另一台计算机,则每个设备都有其周期以及其各自的令牌。
可以在逻辑上和物理上(即在另一台服务器上)从用户信息中分离生成令牌的逻辑。但是在我们的示例中,我们将通过演示逻辑上但不是物理上的分离来简化。身份验证机制逻辑将被创建为安全API的子域,因为存在对用户进行身份验证的依赖性。由于它们具有TTL,因此我们将使用Redis。
通常,在讨论代码时,我们将充分利用本文第二部分中的所有存储和用户管理逻辑,不同之处在于机制会有所不同。我们将添加其他一些用于处理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