From efa8bb02bca5ba7f58777c42f9701915d5efb297 Mon Sep 17 00:00:00 2001 From: Rebecca Dong Date: Fri, 23 Apr 2021 10:12:16 -0700 Subject: [PATCH] Feat: JWT auth and feature flags (#428) Co-authored-by: Shawn Sherwood Co-authored-by: Sam Lichlyter --- .../error/AuthTokenTooLongException.java | 12 + .../nike/cerberus/error/DefaultApiError.java | 4 + .../cerberus/security/CerberusPrincipal.java | 4 + .../event/AuditableEventContextTest.java | 6 +- cerberus-dashboard/package.json | 5 +- .../ViewTokenModal/ViewTokenModal.scss | 18 +- .../cerberus/domain/AuthTokenAcceptType.java | 25 ++ .../nike/cerberus/domain/AuthTokenInfo.java | 37 +++ .../cerberus/domain/AuthTokenIssueType.java | 24 ++ .../cerberus/domain/CerberusAuthToken.java | 112 +++++-- .../nike/cerberus/domain/DomainPojoTest.java | 3 +- cerberus-web/build.gradle | 8 + .../RevokeAuthenticationController.java | 2 +- .../com/nike/cerberus/dao/AuthTokenDao.java | 7 +- .../nike/cerberus/dao/JwtBlocklistDao.java | 50 +++ .../cerberus/jobs/JwtBlocklistCleanUpJob.java | 50 +++ .../cerberus/jobs/JwtBlocklistRefreshJob.java | 46 +++ .../cerberus/jobs/JwtSecretRefreshJob.java | 45 +++ .../nike/cerberus/jwt/CerberusJwtClaims.java | 73 +++++ .../nike/cerberus/jwt/CerberusJwtKeySpec.java | 38 +++ .../jwt/CerberusSigningKeyResolver.java | 300 ++++++++++++++++++ .../java/com/nike/cerberus/jwt/JwtSecret.java | 69 ++++ .../com/nike/cerberus/jwt/JwtSecretData.java | 26 ++ .../cerberus/mapper/JwtBlocklistMapper.java | 30 ++ .../nike/cerberus/record/AuthTokenRecord.java | 3 +- .../cerberus/record/JwtBlocklistRecord.java | 36 +++ .../cerberus/security/JwtTokenFilter.java | 28 ++ .../security/WebSecurityConfiguration.java | 4 + .../cerberus/service/AuthTokenService.java | 154 +++++++-- .../service/AuthenticationService.java | 11 +- .../nike/cerberus/service/ConfigService.java | 111 +++++++ .../com/nike/cerberus/service/JwtService.java | 207 ++++++++++++ cerberus-web/src/main/resources/cerberus.yaml | 23 ++ .../cerberus/mapper/JwtBlocklistMapper.xml | 45 +++ .../migration/V1.7.0.0__jwt_blocklist.sql | 15 + .../jwt/CerberusSigningKeyResolverTest.java | 167 ++++++++++ .../nike/cerberus/record/RecordPojoTest.java | 2 +- .../service/AuthTokenServiceTest.java | 159 +++++++++- .../service/AuthenticationServiceTest.java | 28 +- .../nike/cerberus/service/JwtServiceTest.java | 121 +++++++ dependency-check-supressions.xml | 7 + gradle.properties | 2 +- 42 files changed, 2035 insertions(+), 82 deletions(-) create mode 100644 cerberus-core/src/main/java/com/nike/cerberus/error/AuthTokenTooLongException.java create mode 100644 cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenAcceptType.java create mode 100644 cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenInfo.java create mode 100644 cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenIssueType.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/dao/JwtBlocklistDao.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistCleanUpJob.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistRefreshJob.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtSecretRefreshJob.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtClaims.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtKeySpec.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusSigningKeyResolver.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecret.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecretData.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/mapper/JwtBlocklistMapper.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/record/JwtBlocklistRecord.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/security/JwtTokenFilter.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/service/ConfigService.java create mode 100644 cerberus-web/src/main/java/com/nike/cerberus/service/JwtService.java create mode 100644 cerberus-web/src/main/resources/com/nike/cerberus/mapper/JwtBlocklistMapper.xml create mode 100644 cerberus-web/src/main/resources/com/nike/cerberus/migration/V1.7.0.0__jwt_blocklist.sql create mode 100644 cerberus-web/src/test/java/com/nike/cerberus/jwt/CerberusSigningKeyResolverTest.java create mode 100644 cerberus-web/src/test/java/com/nike/cerberus/service/JwtServiceTest.java diff --git a/cerberus-core/src/main/java/com/nike/cerberus/error/AuthTokenTooLongException.java b/cerberus-core/src/main/java/com/nike/cerberus/error/AuthTokenTooLongException.java new file mode 100644 index 000000000..fa7ce366c --- /dev/null +++ b/cerberus-core/src/main/java/com/nike/cerberus/error/AuthTokenTooLongException.java @@ -0,0 +1,12 @@ +package com.nike.cerberus.error; + +public class AuthTokenTooLongException extends Exception { + + private AuthTokenTooLongException() { + super(); + } + + public AuthTokenTooLongException(String message) { + super(message); + } +} diff --git a/cerberus-core/src/main/java/com/nike/cerberus/error/DefaultApiError.java b/cerberus-core/src/main/java/com/nike/cerberus/error/DefaultApiError.java index 83f1f44f0..c16cfcb75 100644 --- a/cerberus-core/src/main/java/com/nike/cerberus/error/DefaultApiError.java +++ b/cerberus-core/src/main/java/com/nike/cerberus/error/DefaultApiError.java @@ -54,6 +54,10 @@ public enum DefaultApiError implements ApiError { /** Supplied credentials are invalid. */ AUTH_BAD_CREDENTIALS(99106, "Invalid credentials", SC_UNAUTHORIZED), + /** User belongs to too many groups, so the jwt token would make header too large */ + AUTH_TOKEN_TOO_LONG( + 99107, "X-Cerberus-Token header would be too long.", SC_INTERNAL_SERVER_ERROR), + /** Category display name is blank. */ CATEGORY_DISPLAY_NAME_BLANK(99200, "Display name may not be blank.", SC_BAD_REQUEST), diff --git a/cerberus-core/src/main/java/com/nike/cerberus/security/CerberusPrincipal.java b/cerberus-core/src/main/java/com/nike/cerberus/security/CerberusPrincipal.java index 62f59fd79..5c62f9c88 100644 --- a/cerberus-core/src/main/java/com/nike/cerberus/security/CerberusPrincipal.java +++ b/cerberus-core/src/main/java/com/nike/cerberus/security/CerberusPrincipal.java @@ -87,6 +87,10 @@ public String getToken() { return cerberusAuthToken.getToken(); } + public String getTokenId() { + return cerberusAuthToken.getId(); + } + public Set getUserGroups() { if (cerberusAuthToken.getGroups() == null) { return new HashSet<>(); diff --git a/cerberus-core/src/test/java/com/nike/cerberus/event/AuditableEventContextTest.java b/cerberus-core/src/test/java/com/nike/cerberus/event/AuditableEventContextTest.java index a38949e0f..74bf8f5b9 100644 --- a/cerberus-core/src/test/java/com/nike/cerberus/event/AuditableEventContextTest.java +++ b/cerberus-core/src/test/java/com/nike/cerberus/event/AuditableEventContextTest.java @@ -26,7 +26,7 @@ public void testCheckAuthTokenIsEmptyIfPrincipleIsNotInstanceOfCerberusAuthToken @Test public void testCheckAuthTokenIsEmptyIfPrincipleIsInstanceOfCerberusAuthToken() { - CerberusAuthToken cerberusAuthToken = new CerberusAuthToken(); + CerberusAuthToken cerberusAuthToken = CerberusAuthToken.Builder.create().build(); auditableEventContext.setPrincipal(cerberusAuthToken); Optional principalAsCerberusPrincipal = auditableEventContext.getPrincipalAsCerberusPrincipal(); @@ -36,9 +36,9 @@ public void testCheckAuthTokenIsEmptyIfPrincipleIsInstanceOfCerberusAuthToken() @Test public void testGetPrincipalNameIfPrincipleIsInstanceOfCerberusAuthToken() { - CerberusAuthToken cerberusAuthToken = new CerberusAuthToken(); String cerberusPrinciple = "cerberusPrinciple"; - cerberusAuthToken.setPrincipal(cerberusPrinciple); + CerberusAuthToken cerberusAuthToken = + CerberusAuthToken.Builder.create().withPrincipal(cerberusPrinciple).build(); auditableEventContext.setPrincipal(cerberusAuthToken); String principalName = auditableEventContext.getPrincipalName(); Assert.assertEquals(cerberusPrinciple, principalName); diff --git a/cerberus-dashboard/package.json b/cerberus-dashboard/package.json index 14188bd92..00c827d8c 100644 --- a/cerberus-dashboard/package.json +++ b/cerberus-dashboard/package.json @@ -21,6 +21,7 @@ "axios": "^0.21.1", "cookie": "0.4.1", "downloadjs": "1.4.7", + "handlebars": "^4.7.7", "humps": "2.0.1", "lodash": "^4.17.21", "loglevel": "1.7.1", @@ -28,7 +29,6 @@ "prop-types": "^15.7.2", "react": "15.7", "react-addons-create-fragment": "15.6.2", - "react-transition-group": "2.9.0", "react-addons-shallow-compare": "15.6.3", "react-copy-to-clipboard": "5.0.3", "react-dom": "15.7.0", @@ -39,6 +39,7 @@ "react-router-redux": "4.0.8", "react-select": "1.3.0", "react-simple-file-input": "2.1.0", + "react-transition-group": "2.9.0", "redux": "3.7.2", "redux-form": "5.3.6", "redux-logger": "3.0.6", @@ -52,7 +53,7 @@ "eslint-loader": "4.0.2", "eslint-plugin-react": "7.22.0", "estraverse-fb": "1.3.2", - "react-scripts": "4.0.1", + "react-scripts": "4.0.3", "redux-devtools": "3.7.0", "bufferutil": "^4.0.3", "utf-8-validate": "^5.0.4", diff --git a/cerberus-dashboard/src/components/ViewTokenModal/ViewTokenModal.scss b/cerberus-dashboard/src/components/ViewTokenModal/ViewTokenModal.scss index bce02545e..326be5cac 100644 --- a/cerberus-dashboard/src/components/ViewTokenModal/ViewTokenModal.scss +++ b/cerberus-dashboard/src/components/ViewTokenModal/ViewTokenModal.scss @@ -100,14 +100,28 @@ font-size: 16px; font-weight: bold; } - .view-token-modal-data-token-value { + height: 100px; + width: 600px; + overflow-y: scroll; + overflow-wrap: break-word; padding-left: 5px; margin-left: 140px; margin-top: 3px; } - .view-token-modal-data-date-value { + .view-token-modal-data-token-value::-webkit-scrollbar { + width: 7px; + background-color: $snkrs_medium_grey; + } + + .view-token-modal-data-token-value::-webkit-scrollbar-thumb { + height: 18px; + border-radius: 5px; + background-color: $snkrs_dark_grey; + } + + .view-token-modal-data-date-value { padding-left: 5px; margin-left: 17px; margin-top: 3px; diff --git a/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenAcceptType.java b/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenAcceptType.java new file mode 100644 index 000000000..895447cae --- /dev/null +++ b/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenAcceptType.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.nike.cerberus.domain; + +/** Enum used to distinguish between JWT and session token */ +public enum AuthTokenAcceptType { + JWT, + SESSION, + ALL +} diff --git a/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenInfo.java b/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenInfo.java new file mode 100644 index 000000000..b2d2b3985 --- /dev/null +++ b/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenInfo.java @@ -0,0 +1,37 @@ +package com.nike.cerberus.domain; + +import java.time.OffsetDateTime; + +public interface AuthTokenInfo { + String getId(); + + AuthTokenInfo setId(String id); + + OffsetDateTime getCreatedTs(); + + AuthTokenInfo setCreatedTs(OffsetDateTime createdTs); + + OffsetDateTime getExpiresTs(); + + AuthTokenInfo setExpiresTs(OffsetDateTime expiresTs); + + String getPrincipal(); + + AuthTokenInfo setPrincipal(String principal); + + String getPrincipalType(); + + AuthTokenInfo setPrincipalType(String principalType); + + Boolean getIsAdmin(); + + AuthTokenInfo setIsAdmin(Boolean admin); + + String getGroups(); + + AuthTokenInfo setGroups(String groups); + + Integer getRefreshCount(); + + AuthTokenInfo setRefreshCount(Integer refreshCount); +} diff --git a/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenIssueType.java b/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenIssueType.java new file mode 100644 index 000000000..7e6a19c77 --- /dev/null +++ b/cerberus-domain/src/main/java/com/nike/cerberus/domain/AuthTokenIssueType.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.nike.cerberus.domain; + +/** Enum used to distinguish between JWT and session token */ +public enum AuthTokenIssueType { + JWT, + SESSION +} diff --git a/cerberus-domain/src/main/java/com/nike/cerberus/domain/CerberusAuthToken.java b/cerberus-domain/src/main/java/com/nike/cerberus/domain/CerberusAuthToken.java index 8c9c24736..de69d69e3 100644 --- a/cerberus-domain/src/main/java/com/nike/cerberus/domain/CerberusAuthToken.java +++ b/cerberus-domain/src/main/java/com/nike/cerberus/domain/CerberusAuthToken.java @@ -1,11 +1,11 @@ /* - * Copyright (c) 2020 Nike, inc. + * Copyright (c) 2017 Nike, Inc. * - * Licensed under the Apache License, Version 2.0 (the "License") + * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -19,24 +19,96 @@ import com.nike.cerberus.PrincipalType; import java.io.Serializable; import java.time.OffsetDateTime; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor +import lombok.Getter; + public class CerberusAuthToken implements Serializable { + private static final long serialVersionUID = 703097175899198451L; - private String token; - private OffsetDateTime created; - private OffsetDateTime expires; - private String principal; - private PrincipalType principalType; - private boolean isAdmin; - private String groups; - private int refreshCount; + @Getter private String token; + @Getter private OffsetDateTime created; + @Getter private OffsetDateTime expires; + @Getter private String principal; + @Getter private PrincipalType principalType; + @Getter private boolean isAdmin; + @Getter private String groups; + @Getter private int refreshCount; + @Getter private String id; + + public static final class Builder { + private String token; + private OffsetDateTime created; + private OffsetDateTime expires; + private String principal; + private PrincipalType principalType; + private boolean isAdmin; + private String groups; + private int refreshCount; + private String id; + + private Builder() {} + + public static Builder create() { + return new Builder(); + } + + public Builder withToken(String token) { + this.token = token; + return this; + } + + public Builder withCreated(OffsetDateTime created) { + this.created = created; + return this; + } + + public Builder withExpires(OffsetDateTime expires) { + this.expires = expires; + return this; + } + + public Builder withPrincipal(String principal) { + this.principal = principal; + return this; + } + + public Builder withPrincipalType(PrincipalType principalType) { + this.principalType = principalType; + return this; + } + + public Builder withIsAdmin(boolean isAdmin) { + this.isAdmin = isAdmin; + return this; + } + + public Builder withGroups(String groups) { + this.groups = groups; + return this; + } + + public Builder withRefreshCount(int refreshCount) { + this.refreshCount = refreshCount; + return this; + } + + public Builder withId(String id) { + this.id = id; + return this; + } + + public CerberusAuthToken build() { + CerberusAuthToken generateTokenResult = new CerberusAuthToken(); + generateTokenResult.refreshCount = this.refreshCount; + generateTokenResult.principal = this.principal; + generateTokenResult.token = this.token; + generateTokenResult.isAdmin = this.isAdmin; + generateTokenResult.expires = this.expires; + generateTokenResult.groups = this.groups; + generateTokenResult.principalType = this.principalType; + generateTokenResult.created = this.created; + generateTokenResult.id = this.id; + return generateTokenResult; + } + } } diff --git a/cerberus-domain/src/test/java/com/nike/cerberus/domain/DomainPojoTest.java b/cerberus-domain/src/test/java/com/nike/cerberus/domain/DomainPojoTest.java index 698627361..33585da85 100644 --- a/cerberus-domain/src/test/java/com/nike/cerberus/domain/DomainPojoTest.java +++ b/cerberus-domain/src/test/java/com/nike/cerberus/domain/DomainPojoTest.java @@ -36,8 +36,7 @@ public void test_pojo_structure_and_behavior() { List pojoClasses = PojoClassFactory.getPojoClasses("com.nike.cerberus.domain"); pojoClasses.remove(PojoClassFactory.getPojoClass(CerberusAuthToken.class)); - pojoClasses.remove( - PojoClassFactory.getPojoClass(CerberusAuthToken.CerberusAuthTokenBuilder.class)); + pojoClasses.remove(PojoClassFactory.getPojoClass(CerberusAuthToken.Builder.class)); pojoClasses.remove(PojoClassFactory.getPojoClass(VaultStyleErrorResponse.Builder.class)); pojoClasses.remove(PojoClassFactory.getPojoClass(IamPrincipalPermission.Builder.class)); pojoClasses.remove(PojoClassFactory.getPojoClass(UserGroupPermission.Builder.class)); diff --git a/cerberus-web/build.gradle b/cerberus-web/build.gradle index e8a0a2f4b..30f7911ea 100644 --- a/cerberus-web/build.gradle +++ b/cerberus-web/build.gradle @@ -64,9 +64,17 @@ dependencies { implementation "com.amazonaws:aws-java-sdk-core:${versions.awsSdkVersion}" implementation "com.amazonaws:aws-java-sdk-kms:${versions.awsSdkVersion}" implementation "com.amazonaws:aws-java-sdk-sts:${versions.awsSdkVersion}" + implementation "com.amazonaws:aws-java-sdk-s3:${versions.awsSdkVersion}" implementation "com.amazonaws:aws-java-sdk-secretsmanager:${versions.awsSdkVersion}" implementation 'com.amazonaws:aws-encryption-sdk-java:1.6.1' + + // JWT + implementation "io.jsonwebtoken:jjwt-api:0.10.8" + implementation "io.jsonwebtoken:jjwt-impl:0.10.8" + implementation "io.jsonwebtoken:jjwt-jackson:0.10.8" + + //dist tracing implementation 'com.nike.wingtips:wingtips-spring-boot:0.23.1' diff --git a/cerberus-web/src/main/java/com/nike/cerberus/controller/authentication/RevokeAuthenticationController.java b/cerberus-web/src/main/java/com/nike/cerberus/controller/authentication/RevokeAuthenticationController.java index 685ddd489..4e6ac88c1 100644 --- a/cerberus-web/src/main/java/com/nike/cerberus/controller/authentication/RevokeAuthenticationController.java +++ b/cerberus-web/src/main/java/com/nike/cerberus/controller/authentication/RevokeAuthenticationController.java @@ -40,6 +40,6 @@ public RevokeAuthenticationController(AuthenticationService authenticationServic @RequestMapping(method = DELETE) public void revokeAuthentication(Authentication authentication) { var cerberusPrincipal = (CerberusPrincipal) authentication; - authenticationService.revoke(cerberusPrincipal.getToken()); + authenticationService.revoke(cerberusPrincipal, cerberusPrincipal.getTokenExpires()); } } diff --git a/cerberus-web/src/main/java/com/nike/cerberus/dao/AuthTokenDao.java b/cerberus-web/src/main/java/com/nike/cerberus/dao/AuthTokenDao.java index f2014d3db..c4acb7c60 100644 --- a/cerberus-web/src/main/java/com/nike/cerberus/dao/AuthTokenDao.java +++ b/cerberus-web/src/main/java/com/nike/cerberus/dao/AuthTokenDao.java @@ -41,7 +41,12 @@ public int createAuthToken(AuthTokenRecord record) { } public Optional getAuthTokenFromHash(String hash) { - return Optional.ofNullable(authTokenMapper.getAuthTokenFromHash(hash)); + Optional authTokenRecord = + Optional.ofNullable(authTokenMapper.getAuthTokenFromHash(hash)); + if (authTokenRecord.isEmpty()) { + logger.warn("Failed to get auth token from hash"); + } + return authTokenRecord; } public void deleteAuthTokenFromHash(String hash) { diff --git a/cerberus-web/src/main/java/com/nike/cerberus/dao/JwtBlocklistDao.java b/cerberus-web/src/main/java/com/nike/cerberus/dao/JwtBlocklistDao.java new file mode 100644 index 000000000..64c14f057 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/dao/JwtBlocklistDao.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.dao; + +import com.nike.cerberus.mapper.JwtBlocklistMapper; +import com.nike.cerberus.record.JwtBlocklistRecord; +import java.util.HashSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class JwtBlocklistDao { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + private final JwtBlocklistMapper jwtBlocklistMapper; + + @Autowired + public JwtBlocklistDao(JwtBlocklistMapper jwtBlocklistMapper) { + this.jwtBlocklistMapper = jwtBlocklistMapper; + } + + public HashSet getBlocklist() { + return jwtBlocklistMapper.getBlocklist(); + } + + public int addToBlocklist(JwtBlocklistRecord jwtBlocklistRecord) { + return jwtBlocklistMapper.addToBlocklist(jwtBlocklistRecord); + } + + public int deleteExpiredTokens() { + return jwtBlocklistMapper.deleteExpiredTokens(); + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistCleanUpJob.java b/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistCleanUpJob.java new file mode 100644 index 000000000..7c2a02e0d --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistCleanUpJob.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.jobs; + +import com.nike.cerberus.service.JwtService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** Periodically clean up JWT blocklist. */ +@Slf4j +@ConditionalOnProperty("cerberus.jobs.jwtBlocklistCleanUpJob.enabled") +@Component +public class JwtBlocklistCleanUpJob extends LockingJob { + + private final JwtService jwtService; + + @Autowired + public JwtBlocklistCleanUpJob(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + @Scheduled(cron = "${cerberus.jobs.jwtBlocklistCleanUpJob.cronExpression}") + public void execute() { + super.execute(); + } + + @Override + protected void executeLockableCode() { + int numberOfDeletedTokens = jwtService.deleteExpiredTokens(); + log.info("Deleted {} JWT blocklist entries", numberOfDeletedTokens); + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistRefreshJob.java b/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistRefreshJob.java new file mode 100644 index 000000000..e6f7a4b88 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtBlocklistRefreshJob.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.nike.cerberus.jobs; + +import com.nike.cerberus.service.JwtService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** Periodically refresh JWT blocklist. */ +@Slf4j +@ConditionalOnProperty("cerberus.jobs.jwtBlocklistRefreshJob.enabled") +@Component +public class JwtBlocklistRefreshJob { + + private final JwtService jwtService; + + @Autowired + public JwtBlocklistRefreshJob(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Scheduled(cron = "${cerberus.jobs.jwtBlocklistRefreshJob.cronExpression}") + public void execute() { + // This would be too spammy as an info message + log.debug("Running JWT blocklist refresh job"); + jwtService.refreshBlocklist(); + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtSecretRefreshJob.java b/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtSecretRefreshJob.java new file mode 100644 index 000000000..77aa5ee0e --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jobs/JwtSecretRefreshJob.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.nike.cerberus.jobs; + +import com.nike.cerberus.service.JwtService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** Periodically refresh JWT signing keys. */ +@Slf4j +@ConditionalOnProperty("cerberus.jobs.jwtSecretRefreshJob.enabled") +@Component +public class JwtSecretRefreshJob { + + private final JwtService jwtService; + + @Autowired + public JwtSecretRefreshJob(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Scheduled(cron = "${cerberus.jobs.jwtSecretRefreshJob.cronExpression}") + public void execute() { + log.debug("Running JWT secret refresh job"); + jwtService.refreshKeys(); + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtClaims.java b/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtClaims.java new file mode 100644 index 000000000..f6d64129f --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtClaims.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.jwt; + +import com.nike.cerberus.domain.AuthTokenInfo; +import java.time.OffsetDateTime; +import lombok.Getter; + +public class CerberusJwtClaims implements AuthTokenInfo { + + @Getter private String id; + @Getter private OffsetDateTime createdTs; + @Getter private OffsetDateTime expiresTs; + @Getter private String principal; + @Getter private String principalType; + @Getter private Boolean isAdmin; + @Getter private String groups; + @Getter private Integer refreshCount; + + public CerberusJwtClaims setId(String id) { + this.id = id; + return this; + } + + public CerberusJwtClaims setCreatedTs(OffsetDateTime createdTs) { + this.createdTs = createdTs; + return this; + } + + public CerberusJwtClaims setExpiresTs(OffsetDateTime expiresTs) { + this.expiresTs = expiresTs; + return this; + } + + public CerberusJwtClaims setPrincipal(String principal) { + this.principal = principal; + return this; + } + + public CerberusJwtClaims setPrincipalType(String principalType) { + this.principalType = principalType; + return this; + } + + public CerberusJwtClaims setIsAdmin(Boolean admin) { + isAdmin = admin; + return this; + } + + public CerberusJwtClaims setGroups(String groups) { + this.groups = groups; + return this; + } + + public CerberusJwtClaims setRefreshCount(Integer refreshCount) { + this.refreshCount = refreshCount; + return this; + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtKeySpec.java b/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtKeySpec.java new file mode 100644 index 000000000..40a14c37b --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusJwtKeySpec.java @@ -0,0 +1,38 @@ +package com.nike.cerberus.jwt; + +import java.util.Objects; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class CerberusJwtKeySpec extends SecretKeySpec { + // todo maybe implement destroyable + private String kid; + + public CerberusJwtKeySpec(byte[] key, String algorithm, String kid) { + super(key, algorithm); + this.kid = kid; + } + + public CerberusJwtKeySpec(SecretKey secretKey, String kid) { + super(secretKey.getEncoded(), secretKey.getAlgorithm()); + this.kid = kid; + } + + public String getKid() { + return kid; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + CerberusJwtKeySpec keySpec = (CerberusJwtKeySpec) o; + return kid.equals(keySpec.kid); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), kid); + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusSigningKeyResolver.java b/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusSigningKeyResolver.java new file mode 100644 index 000000000..83108d8e0 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jwt/CerberusSigningKeyResolver.java @@ -0,0 +1,300 @@ +package com.nike.cerberus.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nike.cerberus.service.ConfigService; +import com.nike.cerberus.util.UuidSupplier; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.SigningKeyResolverAdapter; +import io.jsonwebtoken.security.Keys; +import java.io.IOException; +import java.security.Key; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import javax.crypto.SecretKey; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * A subclass of {@link SigningKeyResolverAdapter} that resolves the key used for JWT signing and + * signature validation + */ +@Component +public class CerberusSigningKeyResolver extends SigningKeyResolverAdapter { + + private ConfigService configService; + private final ObjectMapper objectMapper; + private CerberusJwtKeySpec signingKey; + private Map keyMap; + private boolean checkKeyRotation; + private long nextRotationTs; + private String nextKeyId; + + // Hardcoding these for now + private static final String DEFAULT_ALGORITHM = "HmacSHA512"; + private static final String DEFAULT_JWT_ALG_HEADER = "HS512"; + private static final int DEFAULT_MINIMUM_KEY_LENGTH_IN_BYTES = 512 / 8; + + protected final Logger log = LoggerFactory.getLogger(this.getClass()); + + @Autowired + public CerberusSigningKeyResolver( + JwtServiceOptionalPropertyHolder jwtServiceOptionalPropertyHolder, + ObjectMapper objectMapper, + Optional configService, + @Value("${cerberus.auth.jwt.secret.local.autoGenerate}") boolean autoGenerate, + @Value("${cerberus.auth.jwt.secret.local.enabled}") boolean jwtLocalEnabled, + UuidSupplier uuidSupplier) { + this.configService = configService.orElse(null); + this.objectMapper = objectMapper; + + // Override key with properties, useful for local development + if (jwtLocalEnabled) { + if (autoGenerate) { + log.info("Auto generating JWT secret for local development"); + SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.forName(DEFAULT_JWT_ALG_HEADER)); + this.signingKey = new CerberusJwtKeySpec(key, uuidSupplier.get()); + } else { + log.info("Using JWT secret from properties"); + if (!StringUtils.isBlank(jwtServiceOptionalPropertyHolder.jwtSecretLocalMaterial) + && !StringUtils.isBlank(jwtServiceOptionalPropertyHolder.jwtSecretLocalKeyId)) { + byte[] key = + Base64.getDecoder().decode(jwtServiceOptionalPropertyHolder.jwtSecretLocalMaterial); + this.signingKey = + new CerberusJwtKeySpec( + key, DEFAULT_ALGORITHM, jwtServiceOptionalPropertyHolder.jwtSecretLocalKeyId); + } else { + throw new IllegalArgumentException( + "Invalid JWT config. To resolve, either set " + + "cms.auth.jwt.secret.local.autoGenerate=true or provide both cms.auth.jwt.secret.local.material" + + " and cms.auth.jwt.secret.local.kid"); + } + } + rotateKeyMap(signingKey); + } else { + log.info("Initializing JWT key resolver using Jwt Secret from S3 bucket"); + refresh(); + } + } + + /** + * This 'holder' class allows optional injection of Cerberus JWT-specific properties that are only + * necessary for local development. + */ + @Component + static class JwtServiceOptionalPropertyHolder { + @Value("${cms.auth.jwt.secret.local.material: #{null}}") + String jwtSecretLocalMaterial; + + @Value("${cms.auth.jwt.secret.local.kid: #{null}}") + String jwtSecretLocalKeyId; + } + + @Override + public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) { + // Rejects non HS512 token + if (!StringUtils.equals(DEFAULT_JWT_ALG_HEADER, jwsHeader.getAlgorithm())) { + throw new IllegalArgumentException("Algorithm not supported"); + } + String keyId = jwsHeader.getKeyId(); + Key key = lookupVerificationKey(keyId); + + return key; + } + + /** + * Return the signing key that should be used to sign JWT. The signing key is defined as the + * "newest active key" i.e. key with the biggest effectiveTs value and effectiveTs before now. + * + * @return The signing key + */ + public CerberusJwtKeySpec resolveSigningKey() { + if (checkKeyRotation) { + rotateSigningKey(); + return signingKey; + } else { + return signingKey; + } + } + + /** Poll for JWT config and update key map with new data */ + public void refresh() { + JwtSecretData jwtSecretData = getJwtSecretData(); + + rotateKeyMap(jwtSecretData); + setSigningKey(jwtSecretData); + } + + /** + * Poll for JWT config and validate new data + * + * @return JWT config + */ + protected JwtSecretData getJwtSecretData() { + String jwtSecretsString = configService.getJwtSecrets(); + try { + JwtSecretData jwtSecretData = objectMapper.readValue(jwtSecretsString, JwtSecretData.class); + validateJwtSecretData(jwtSecretData); + return jwtSecretData; + } catch (IOException e) { + log.error("IOException encountered during deserialization of jwt secret data"); + throw new RuntimeException(e); + } + } + + /** + * Validate {@link JwtSecretData}. Validates required fields and rejects weak keys. + * + * @param jwtSecretData JWT config + */ + protected void validateJwtSecretData(JwtSecretData jwtSecretData) { + if (jwtSecretData == null || jwtSecretData.getJwtSecrets() == null) { + throw new IllegalArgumentException("JWT secret data cannot be null"); + } + if (jwtSecretData.getJwtSecrets().isEmpty()) { + throw new IllegalArgumentException("JWT secret data cannot be empty"); + } + + long minEffectiveTs = 0; + + for (JwtSecret jwtSecret : jwtSecretData.getJwtSecrets()) { + if (jwtSecret.getSecret() == null) { + throw new IllegalArgumentException("JWT secret cannot be null"); + } + if (Base64.getDecoder().decode(jwtSecret.getSecret()).length + < DEFAULT_MINIMUM_KEY_LENGTH_IN_BYTES) { + throw new IllegalArgumentException( + "JWT secret does NOT meet minimum length requirement of " + + DEFAULT_MINIMUM_KEY_LENGTH_IN_BYTES); + } + if (StringUtils.isBlank(jwtSecret.getId())) { + throw new IllegalArgumentException("JWT secret key ID cannot be empty"); + } + minEffectiveTs = Math.min(minEffectiveTs, jwtSecret.getEffectiveTs()); + } + + long now = System.currentTimeMillis(); + if (now < minEffectiveTs) { + // Prevents rotation or start up if no key is active + throw new IllegalArgumentException("Requires at least 1 active JWT secret"); + } + } + + /** + * Set the signing key that should be used to sign JWT and the next signing key in line. The + * signing key is defined as the "newest active key" i.e. key with the biggest effectiveTs value + * and effectiveTs before now. + * + * @param jwtSecretData JWT config + */ + protected void setSigningKey(JwtSecretData jwtSecretData) { + // Find the active key + long now = System.currentTimeMillis(); + String currentKeyId = getSigningKeyId(jwtSecretData, now); + signingKey = keyMap.get(currentKeyId); + + // Find the next key + List futureJwtSecrets = getFutureJwtSecrets(jwtSecretData, now); + + // Set up rotation + if (!futureJwtSecrets.isEmpty()) { + JwtSecret jwtSecret = futureJwtSecrets.get(0); + checkKeyRotation = true; + nextRotationTs = jwtSecret.getEffectiveTs(); + nextKeyId = jwtSecret.getId(); + } else { + checkKeyRotation = false; + } + } + + /** + * Get future signing keys i.e. keys with effectiveTs after now. + * + * @param jwtSecretData JWT config + * @param now Timestamp of now + * @return Future signing keys + */ + protected List getFutureJwtSecrets(JwtSecretData jwtSecretData, long now) { + return jwtSecretData.getJwtSecrets().stream() + .filter(secretData -> secretData.getEffectiveTs() > now) + .sorted( + (secretData1, secretData2) -> + secretData1.getEffectiveTs() - secretData2.getEffectiveTs() < 0 ? -1 : 1) + // this puts older keys in the front of the list + .collect(Collectors.toList()); + } + + /** + * Get the ID of signing key that should be used to sign JWT. The signing key is defined as the + * "newest active key" i.e. key with the biggest effectiveTs value and effectiveTs before now. + * + * @param jwtSecretData JWT config + * @param now Timestamp of now in millisecond + * @return ID of the signing key + */ + protected String getSigningKeyId(JwtSecretData jwtSecretData, long now) { + List sortedJwtSecrets = + jwtSecretData.getJwtSecrets().stream() + .filter(secretData -> secretData.getEffectiveTs() <= now) + .sorted( + (secretData1, secretData2) -> + secretData1.getEffectiveTs() - secretData2.getEffectiveTs() > 0 + ? -1 + : 1) // this puts newer keys in the front of the list + .collect(Collectors.toList()); + String currentKeyId = sortedJwtSecrets.get(0).getId(); + return currentKeyId; + } + + private void rotateKeyMap(JwtSecretData jwtSecretData) { + ConcurrentHashMap keyMap = new ConcurrentHashMap<>(); + for (JwtSecret jwtSecret : jwtSecretData.getJwtSecrets()) { + CerberusJwtKeySpec keySpec = + new CerberusJwtKeySpec( + Base64.getDecoder().decode(jwtSecret.getSecret()), + DEFAULT_ALGORITHM, + jwtSecret.getId()); + keyMap.put(jwtSecret.getId(), keySpec); + } + this.keyMap = keyMap; + } + + private void rotateKeyMap(CerberusJwtKeySpec cerberusJwtKeySpec) { + ConcurrentHashMap keyMap = new ConcurrentHashMap<>(); + keyMap.put(cerberusJwtKeySpec.getKid(), cerberusJwtKeySpec); + this.keyMap = keyMap; + } + + private Key lookupVerificationKey(String keyId) { + if (StringUtils.isBlank(keyId)) { + throw new IllegalArgumentException("Key ID cannot be empty"); + } + try { + CerberusJwtKeySpec keySpec = keyMap.get(keyId); + if (keySpec == null) { + throw new IllegalArgumentException("The key ID " + keyId + " is invalid or expired"); + } + + return keySpec; + } catch (NullPointerException e) { + throw new IllegalArgumentException("The key ID " + keyId + " is either invalid or expired"); + } + } + + private void rotateSigningKey() { + long now = System.currentTimeMillis(); + if (now >= nextRotationTs) { + this.signingKey = keyMap.get(nextKeyId); + } + checkKeyRotation = false; + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecret.java b/cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecret.java new file mode 100644 index 000000000..cc45f1d18 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecret.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.jwt; + +public class JwtSecret { + private String id; + + private String secret; + + private String algorithm; + + private long effectiveTs; + + private long createdTs; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public long getEffectiveTs() { + return effectiveTs; + } + + public void setEffectiveTs(long effectiveTs) { + this.effectiveTs = effectiveTs; + } + + public long getCreatedTs() { + return createdTs; + } + + public void setCreatedTs(long createdTs) { + this.createdTs = createdTs; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecretData.java b/cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecretData.java new file mode 100644 index 000000000..6e36191cf --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/jwt/JwtSecretData.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.jwt; + +import java.util.LinkedList; +import lombok.Data; + +/** A POJO that represents the JWT config */ +@Data +public class JwtSecretData { + private LinkedList jwtSecrets = new LinkedList<>(); +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/mapper/JwtBlocklistMapper.java b/cerberus-web/src/main/java/com/nike/cerberus/mapper/JwtBlocklistMapper.java new file mode 100644 index 000000000..b8683437b --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/mapper/JwtBlocklistMapper.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.mapper; + +import com.nike.cerberus.record.JwtBlocklistRecord; +import java.util.HashSet; +import org.apache.ibatis.annotations.Param; + +public interface JwtBlocklistMapper { + + HashSet getBlocklist(); + + int addToBlocklist(@Param("record") JwtBlocklistRecord record); + + int deleteExpiredTokens(); +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/record/AuthTokenRecord.java b/cerberus-web/src/main/java/com/nike/cerberus/record/AuthTokenRecord.java index b42844e29..cbe2a7662 100644 --- a/cerberus-web/src/main/java/com/nike/cerberus/record/AuthTokenRecord.java +++ b/cerberus-web/src/main/java/com/nike/cerberus/record/AuthTokenRecord.java @@ -16,9 +16,10 @@ package com.nike.cerberus.record; +import com.nike.cerberus.domain.AuthTokenInfo; import java.time.OffsetDateTime; -public class AuthTokenRecord { +public class AuthTokenRecord implements AuthTokenInfo { private String id; diff --git a/cerberus-web/src/main/java/com/nike/cerberus/record/JwtBlocklistRecord.java b/cerberus-web/src/main/java/com/nike/cerberus/record/JwtBlocklistRecord.java new file mode 100644 index 000000000..e35274979 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/record/JwtBlocklistRecord.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.record; + +import java.time.OffsetDateTime; +import lombok.Getter; + +public class JwtBlocklistRecord { + + @Getter private String id; + @Getter private OffsetDateTime expiresTs; + + public JwtBlocklistRecord setId(String id) { + this.id = id; + return this; + } + + public JwtBlocklistRecord setExpiresTs(OffsetDateTime expiresTs) { + this.expiresTs = expiresTs; + return this; + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/security/JwtTokenFilter.java b/cerberus-web/src/main/java/com/nike/cerberus/security/JwtTokenFilter.java new file mode 100644 index 000000000..03844d2f6 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/security/JwtTokenFilter.java @@ -0,0 +1,28 @@ +package com.nike.cerberus.security; + +import static com.nike.cerberus.security.WebSecurityConfiguration.HEADER_X_CERBERUS_TOKEN; +import static com.nike.cerberus.security.WebSecurityConfiguration.LEGACY_AUTH_TOKN_HEADER; + +import com.nike.cerberus.service.AuthTokenService; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import org.springframework.security.web.util.matcher.RequestMatcher; + +public class JwtTokenFilter extends CerberusAuthenticationFilter { + + private final AuthTokenService authTokenService; + + public JwtTokenFilter( + RequestMatcher requiresAuthenticationRequestMatcher, AuthTokenService authTokenService) { + super(requiresAuthenticationRequestMatcher); + this.authTokenService = authTokenService; + } + + @Override + Optional extractCerberusPrincipalFromRequest(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(HEADER_X_CERBERUS_TOKEN)) + .or(() -> Optional.ofNullable(request.getHeader(LEGACY_AUTH_TOKN_HEADER))) + // If the token is present then use the auth service to map it to a Cerberus Principal + .flatMap(token -> authTokenService.getCerberusAuthToken(token).map(CerberusPrincipal::new)); + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/security/WebSecurityConfiguration.java b/cerberus-web/src/main/java/com/nike/cerberus/security/WebSecurityConfiguration.java index 51b59813c..c5aee2c65 100644 --- a/cerberus-web/src/main/java/com/nike/cerberus/security/WebSecurityConfiguration.java +++ b/cerberus-web/src/main/java/com/nike/cerberus/security/WebSecurityConfiguration.java @@ -98,6 +98,8 @@ protected void configure(HttpSecurity http) throws Exception { new DatabaseTokenAuthenticationProcessingFilter( authTokenService, requestDoesNotRequireAuthMatcher); + var jwtFilter = new JwtTokenFilter(requestDoesNotRequireAuthMatcher, authTokenService); + // Disable CSRF (cross site request forgery) http.csrf().disable(); @@ -117,5 +119,7 @@ protected void configure(HttpSecurity http) throws Exception { // Add the auth filters http.addFilterBefore(dbTokenFilter, UsernamePasswordAuthenticationFilter.class); + + http.addFilterBefore(jwtFilter, dbTokenFilter.getClass()); } } diff --git a/cerberus-web/src/main/java/com/nike/cerberus/service/AuthTokenService.java b/cerberus-web/src/main/java/com/nike/cerberus/service/AuthTokenService.java index 16d2cbf10..255210293 100644 --- a/cerberus-web/src/main/java/com/nike/cerberus/service/AuthTokenService.java +++ b/cerberus-web/src/main/java/com/nike/cerberus/service/AuthTokenService.java @@ -19,23 +19,42 @@ import static com.google.common.base.Preconditions.checkArgument; import static org.springframework.transaction.annotation.Isolation.READ_UNCOMMITTED; +import com.nike.backstopper.exception.ApiException; import com.nike.cerberus.PrincipalType; import com.nike.cerberus.dao.AuthTokenDao; +import com.nike.cerberus.domain.AuthTokenAcceptType; +import com.nike.cerberus.domain.AuthTokenInfo; +import com.nike.cerberus.domain.AuthTokenIssueType; import com.nike.cerberus.domain.CerberusAuthToken; +import com.nike.cerberus.error.AuthTokenTooLongException; +import com.nike.cerberus.error.DefaultApiError; +import com.nike.cerberus.jwt.CerberusJwtClaims; import com.nike.cerberus.record.AuthTokenRecord; +import com.nike.cerberus.security.CerberusPrincipal; import com.nike.cerberus.util.AuthTokenGenerator; +import com.nike.cerberus.util.CustomApiError; import com.nike.cerberus.util.DateTimeSupplier; import com.nike.cerberus.util.TokenHasher; import com.nike.cerberus.util.UuidSupplier; import java.time.OffsetDateTime; import java.util.Optional; +import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +@Data +@Component +@ConfigurationProperties("cerberus.auth.token") +class JwtFeatureFlags { + private AuthTokenIssueType issueType; + private AuthTokenAcceptType acceptType; +} + /** Service for handling authentication tokens. */ @Component public class AuthTokenService { @@ -47,6 +66,9 @@ public class AuthTokenService { private final AuthTokenGenerator authTokenGenerator; private final AuthTokenDao authTokenDao; private final DateTimeSupplier dateTimeSupplier; + private final JwtService jwtService; + + private final JwtFeatureFlags tokenFlag; @Autowired public AuthTokenService( @@ -54,13 +76,17 @@ public AuthTokenService( TokenHasher tokenHasher, AuthTokenGenerator authTokenGenerator, AuthTokenDao authTokenDao, - DateTimeSupplier dateTimeSupplier) { + DateTimeSupplier dateTimeSupplier, + JwtService jwtService, + JwtFeatureFlags tokenFlag) { this.uuidSupplier = uuidSupplier; this.tokenHasher = tokenHasher; this.authTokenGenerator = authTokenGenerator; this.authTokenDao = authTokenDao; this.dateTimeSupplier = dateTimeSupplier; + this.jwtService = jwtService; + this.tokenFlag = tokenFlag; } @Transactional @@ -75,13 +101,54 @@ public CerberusAuthToken generateToken( checkArgument(StringUtils.isNotBlank(principal), "The principal must be set and not empty"); String id = uuidSupplier.get(); - String token = authTokenGenerator.generateSecureToken(); OffsetDateTime now = dateTimeSupplier.get(); - AuthTokenRecord tokenRecord = - new AuthTokenRecord() + switch (tokenFlag.getIssueType()) { + case JWT: + try { + return getCerberusAuthTokenFromJwt( + principal, principalType, isAdmin, groups, ttlInMinutes, refreshCount, id, now); + } catch (AuthTokenTooLongException e) { + final String msg = e.getMessage(); + logger.info(msg); + + if (tokenFlag.getAcceptType() == AuthTokenAcceptType.ALL) { + return getCerberusAuthTokenFromSession( + principal, principalType, isAdmin, groups, ttlInMinutes, refreshCount, id, now); + } + throw ApiException.newBuilder() + .withApiErrors( + CustomApiError.createCustomApiError(DefaultApiError.AUTH_TOKEN_TOO_LONG, msg)) + .withExceptionMessage(msg) + .build(); + } + case SESSION: + return getCerberusAuthTokenFromSession( + principal, principalType, isAdmin, groups, ttlInMinutes, refreshCount, id, now); + default: + throw ApiException.newBuilder() + .withApiErrors(DefaultApiError.INTERNAL_SERVER_ERROR) + .build(); + } + } + + private CerberusAuthToken getCerberusAuthTokenFromJwt( + String principal, + PrincipalType principalType, + boolean isAdmin, + String groups, + long ttlInMinutes, + int refreshCount, + String id, + OffsetDateTime now) + throws AuthTokenTooLongException { + + AuthTokenInfo authTokenInfo; + String token; + + authTokenInfo = + new CerberusJwtClaims() .setId(id) - .setTokenHash(tokenHasher.hashToken(token)) .setCreatedTs(now) .setExpiresTs(now.plusMinutes(ttlInMinutes)) .setPrincipal(principal) @@ -89,29 +156,67 @@ public CerberusAuthToken generateToken( .setIsAdmin(isAdmin) .setGroups(groups) .setRefreshCount(refreshCount); + token = jwtService.generateJwtToken((CerberusJwtClaims) authTokenInfo); + return getCerberusAuthTokenFromRecord(token, authTokenInfo); + } - authTokenDao.createAuthToken(tokenRecord); + private CerberusAuthToken getCerberusAuthTokenFromSession( + String principal, + PrincipalType principalType, + boolean isAdmin, + String groups, + long ttlInMinutes, + int refreshCount, + String id, + OffsetDateTime now) { - return getCerberusAuthTokenFromRecord(token, tokenRecord); + String token = authTokenGenerator.generateSecureToken(); + AuthTokenRecord authTokenRecord = + new AuthTokenRecord() + .setId(id) + .setTokenHash(tokenHasher.hashToken(token)) + .setCreatedTs(now) + .setExpiresTs(now.plusMinutes(ttlInMinutes)) + .setPrincipal(principal) + .setPrincipalType(principalType.getName()) + .setIsAdmin(isAdmin) + .setGroups(groups) + .setRefreshCount(refreshCount); + authTokenDao.createAuthToken(authTokenRecord); + return getCerberusAuthTokenFromRecord(token, authTokenRecord); } private CerberusAuthToken getCerberusAuthTokenFromRecord( - String token, AuthTokenRecord tokenRecord) { - return CerberusAuthToken.builder() - .token(token) - .created(tokenRecord.getCreatedTs()) - .expires(tokenRecord.getExpiresTs()) - .principal(tokenRecord.getPrincipal()) - .principalType(PrincipalType.fromName(tokenRecord.getPrincipalType())) - .isAdmin(tokenRecord.getIsAdmin()) - .groups(tokenRecord.getGroups()) - .refreshCount(tokenRecord.getRefreshCount()) + String token, AuthTokenInfo authTokenInfo) { + return CerberusAuthToken.Builder.create() + .withToken(token) + .withCreated(authTokenInfo.getCreatedTs()) + .withExpires(authTokenInfo.getExpiresTs()) + .withPrincipal(authTokenInfo.getPrincipal()) + .withPrincipalType(PrincipalType.fromName(authTokenInfo.getPrincipalType())) + .withIsAdmin(authTokenInfo.getIsAdmin()) + .withGroups(authTokenInfo.getGroups()) + .withRefreshCount(authTokenInfo.getRefreshCount()) + .withId(authTokenInfo.getId()) .build(); } public Optional getCerberusAuthToken(String token) { - Optional tokenRecord = - authTokenDao.getAuthTokenFromHash(tokenHasher.hashToken(token)); + Optional tokenRecord = Optional.empty(); + AuthTokenAcceptType acceptType = tokenFlag.getAcceptType(); + boolean isJwt = jwtService.isJwt(token); + if (isJwt && (acceptType != AuthTokenAcceptType.SESSION)) { + tokenRecord = jwtService.parseAndValidateToken(token); + } else if (acceptType != AuthTokenAcceptType.JWT) { + tokenRecord = authTokenDao.getAuthTokenFromHash(tokenHasher.hashToken(token)); + } else { + String tokenType = isJwt ? "JWT" : "Session"; + logger.warn( + "Returning empty optional, because token type is {} and only {} are accepted", + tokenType, + acceptType.toString()); + return Optional.empty(); + } OffsetDateTime now = OffsetDateTime.now(); if (tokenRecord.isPresent() && tokenRecord.get().getExpiresTs().isBefore(now)) { @@ -127,9 +232,14 @@ public Optional getCerberusAuthToken(String token) { } @Transactional - public void revokeToken(String token) { - String hash = tokenHasher.hashToken(token); - authTokenDao.deleteAuthTokenFromHash(hash); + public void revokeToken(CerberusPrincipal cerberusPrincipal, OffsetDateTime tokenExpires) { + if (jwtService.isJwt(cerberusPrincipal.getToken())) { + logger.info("Revoking token ID: {}", cerberusPrincipal); + jwtService.revokeToken(cerberusPrincipal.getTokenId(), tokenExpires); + } else { + String hash = tokenHasher.hashToken(cerberusPrincipal.getToken()); + authTokenDao.deleteAuthTokenFromHash(hash); + } } @Transactional( diff --git a/cerberus-web/src/main/java/com/nike/cerberus/service/AuthenticationService.java b/cerberus-web/src/main/java/com/nike/cerberus/service/AuthenticationService.java index 98ae8b496..2186da333 100644 --- a/cerberus-web/src/main/java/com/nike/cerberus/service/AuthenticationService.java +++ b/cerberus-web/src/main/java/com/nike/cerberus/service/AuthenticationService.java @@ -487,7 +487,7 @@ public AuthResponse refreshUserToken(final CerberusPrincipal authPrincipal) { .build(); } - revoke(authPrincipal.getToken()); + revoke(authPrincipal, authPrincipal.getTokenExpires()); final AuthResponse authResponse = new AuthResponse(); authResponse.setStatus(AuthStatus.SUCCESS); @@ -501,9 +501,12 @@ public AuthResponse refreshUserToken(final CerberusPrincipal authPrincipal) { return authResponse; } - /** @param authToken Auth Token to be revoked */ - public void revoke(final String authToken) { - authTokenService.revokeToken(authToken); + /** + * @param cerberusPrincipal Auth principal to be revoked + * @param tokenExpires Token expire timestamp + */ + public void revoke(final CerberusPrincipal cerberusPrincipal, OffsetDateTime tokenExpires) { + authTokenService.revokeToken(cerberusPrincipal, tokenExpires); } /** diff --git a/cerberus-web/src/main/java/com/nike/cerberus/service/ConfigService.java b/cerberus-web/src/main/java/com/nike/cerberus/service/ConfigService.java new file mode 100644 index 000000000..81b3c5fa1 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/service/ConfigService.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.service; + +import static com.nike.cerberus.service.EncryptionService.decrypt; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.encryptionsdk.AwsCrypto; +import com.amazonaws.regions.Region; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.GetObjectRequest; +import com.amazonaws.services.s3.model.S3Object; +import com.amazonaws.util.IOUtils; +import com.nike.cerberus.util.CiphertextUtils; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@ConditionalOnProperty(name = "cerberus.auth.jwt.secret.local.enabled", havingValue = "false") +@Component +public class ConfigService { + + private static final String JWT_SECRETS_PATH = "cms/jwt-secrets.json"; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AmazonS3 s3Client; + + private final String bucketName; + + private final AwsCrypto awsCrypto; + + private final Region currentRegion; + + @Autowired + public ConfigService( + @Value("${cerberus.auth.jwt.secret.bucket}") final String bucketName, + final String region, + AwsCrypto awsCrypto) { + + currentRegion = Region.getRegion(Regions.fromName(region)); + this.s3Client = AmazonS3Client.builder().withRegion(region).build(); + + this.bucketName = bucketName; + this.awsCrypto = awsCrypto; + } + + public String getJwtSecrets() { + return getPlainText(JWT_SECRETS_PATH); + } + + private String getPlainText(String path) { + try { + return decrypt(CiphertextUtils.parse(getCipherText(path)), awsCrypto, currentRegion); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to download and decrypt environment specific properties from s3", e); + } + } + + private String getCipherText(String path) { + final GetObjectRequest request = new GetObjectRequest(bucketName, path); + + try { + S3Object s3Object = s3Client.getObject(request); + InputStream object = s3Object.getObjectContent(); + return IOUtils.toString(object); + } catch (AmazonServiceException ase) { + if (StringUtils.equalsIgnoreCase(ase.getErrorCode(), "NoSuchKey")) { + final String errorMessage = + String.format( + "The S3 object doesn't exist. Bucket: %s, Key: %s", bucketName, request.getKey()); + logger.debug(errorMessage); + throw new IllegalStateException(errorMessage); + } else { + logger.error("Unexpected error communicating with AWS.", ase); + throw ase; + } + } catch (IOException e) { + String errorMessage = + String.format( + "Unable to read contents of S3 object. Bucket: %s, Key: %s, Expected Encoding: %s", + bucketName, request.getKey(), Charset.defaultCharset()); + logger.error(errorMessage); + throw new IllegalStateException(errorMessage, e); + } + } +} diff --git a/cerberus-web/src/main/java/com/nike/cerberus/service/JwtService.java b/cerberus-web/src/main/java/com/nike/cerberus/service/JwtService.java new file mode 100644 index 000000000..89b25a9d5 --- /dev/null +++ b/cerberus-web/src/main/java/com/nike/cerberus/service/JwtService.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2021 Nike, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nike.cerberus.service; + +import static io.jsonwebtoken.JwtParser.SEPARATOR_CHAR; +import static org.springframework.transaction.annotation.Isolation.READ_UNCOMMITTED; + +import com.nike.cerberus.dao.JwtBlocklistDao; +import com.nike.cerberus.error.AuthTokenTooLongException; +import com.nike.cerberus.jwt.CerberusJwtClaims; +import com.nike.cerberus.jwt.CerberusJwtKeySpec; +import com.nike.cerberus.jwt.CerberusSigningKeyResolver; +import com.nike.cerberus.record.JwtBlocklistRecord; +import io.jsonwebtoken.*; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.HashSet; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** Service for generating, parsing, and validating JWT tokens. */ +@Component +@ComponentScan(basePackages = {"com.nike.cerberus.jwt", "com.nike.cerberus.dao"}) +public class JwtService { + + protected final Logger log = LoggerFactory.getLogger(getClass()); + private static final String PRINCIPAL_TYPE_CLAIM_NAME = "principalType"; + private static final String GROUP_CLAIM_NAME = "groups"; + private static final String IS_ADMIN_CLAIM_NAME = "isAdmin"; + private static final String REFRESH_COUNT_CLAIM_NAME = "refreshCount"; + private static final int NUM_SEPARATORS_IN_JWT_TOKEN = 2; + + // Max header line length for an AWS ALB is 16k, so it needs to be less + @Value("${cerberus.auth.jwt.maxTokenLength:#{16000}}") + private int maxTokenLength; + + private final CerberusSigningKeyResolver signingKeyResolver; + private final String environmentName; + private final JwtBlocklistDao jwtBlocklistDao; + + private HashSet blocklist; + + @Autowired + public JwtService( + CerberusSigningKeyResolver signingKeyResolver, + @Value("cerberus.environmentName") String environmentName, + JwtBlocklistDao jwtBlocklistDao) { + this.signingKeyResolver = signingKeyResolver; + this.environmentName = environmentName; + this.jwtBlocklistDao = jwtBlocklistDao; + refreshBlocklist(); + } + + /** + * Generate JWT token + * + * @param cerberusJwtClaims Cerberus JWT claims + * @return JWT token + */ + public String generateJwtToken(CerberusJwtClaims cerberusJwtClaims) + throws AuthTokenTooLongException { + CerberusJwtKeySpec cerberusJwtKeySpec = signingKeyResolver.resolveSigningKey(); + String principal = cerberusJwtClaims.getPrincipal(); + + String jwtToken = + Jwts.builder() + .setHeaderParam(JwsHeader.KEY_ID, cerberusJwtKeySpec.getKid()) + .setId(cerberusJwtClaims.getId()) + .setIssuer(environmentName) + .setSubject(principal) + .claim(PRINCIPAL_TYPE_CLAIM_NAME, cerberusJwtClaims.getPrincipalType()) + .claim(GROUP_CLAIM_NAME, cerberusJwtClaims.getGroups()) + .claim(IS_ADMIN_CLAIM_NAME, cerberusJwtClaims.getIsAdmin()) + .claim(REFRESH_COUNT_CLAIM_NAME, cerberusJwtClaims.getRefreshCount()) + .setExpiration(Date.from(cerberusJwtClaims.getExpiresTs().toInstant())) + .setIssuedAt(Date.from(cerberusJwtClaims.getCreatedTs().toInstant())) + .signWith(cerberusJwtKeySpec) + .compressWith(CompressionCodecs.GZIP) + .compact(); + + int tokenLength = jwtToken.length(); + log.info("{}: JWT length: {}", principal, tokenLength); + if (tokenLength > maxTokenLength) { + String msg = + String.format( + "Token for %s is %d characters long. The max is %d bytes.", + principal, tokenLength, maxTokenLength); + throw new AuthTokenTooLongException(msg); + } + return jwtToken; + } + + /** + * Parse and validate JWT token + * + * @param token JWT token + * @return Cerberus JWT claims + */ + public Optional parseAndValidateToken(String token) { + Jws claimsJws; + try { + claimsJws = + Jwts.parser() + .requireIssuer(environmentName) + .setSigningKeyResolver(signingKeyResolver) + .parseClaimsJws(token); + } catch (InvalidClaimException e) { + log.warn("Invalid claim when parsing token: {}", token, e); + return Optional.empty(); + } catch (JwtException e) { + log.warn("Error parsing JWT token: {}", token, e); + return Optional.empty(); + } catch (IllegalArgumentException e) { + log.warn("Error parsing JWT token: {}", token, e); + return Optional.empty(); + } + Claims claims = claimsJws.getBody(); + if (blocklist.contains(claims.getId())) { + log.warn("This JWT token is blocklisted. ID: {}", claims.getId()); + return Optional.empty(); + } + String subject = claims.getSubject(); + CerberusJwtClaims cerberusJwtClaims = + new CerberusJwtClaims() + .setId(claims.getId()) + .setPrincipal(subject) + .setExpiresTs( + OffsetDateTime.ofInstant( + claims.getExpiration().toInstant(), ZoneId.systemDefault())) + .setCreatedTs( + OffsetDateTime.ofInstant(claims.getIssuedAt().toInstant(), ZoneId.systemDefault())) + .setPrincipalType(claims.get(PRINCIPAL_TYPE_CLAIM_NAME, String.class)) + .setGroups(claims.get(GROUP_CLAIM_NAME, String.class)) + .setIsAdmin(claims.get(IS_ADMIN_CLAIM_NAME, Boolean.class)) + .setRefreshCount(claims.get(REFRESH_COUNT_CLAIM_NAME, Integer.class)); + + return Optional.of(cerberusJwtClaims); + } + + /** Refresh signing keys in {@link CerberusSigningKeyResolver} */ + public void refreshKeys() { + signingKeyResolver.refresh(); + } + + /** Refresh JWT blocklist */ + public void refreshBlocklist() { + blocklist = jwtBlocklistDao.getBlocklist(); + } + + /** + * Revoke JWT + * + * @param id JWT ID + * @param tokenExpires Expiration timestamp of the JWT + */ + public void revokeToken(String id, OffsetDateTime tokenExpires) { + blocklist.add(id); + JwtBlocklistRecord jwtBlocklistRecord = + new JwtBlocklistRecord().setId(id).setExpiresTs(tokenExpires); + jwtBlocklistDao.addToBlocklist(jwtBlocklistRecord); + } + + /** + * Delete JWT blocklist entries that have expired + * + * @return + */ + @Transactional( + isolation = READ_UNCOMMITTED // allow dirty reads so we don't block other threads + ) + public int deleteExpiredTokens() { + return jwtBlocklistDao.deleteExpiredTokens(); + } + + /** + * Return if the token looks like a JWT. Technically a JWT can have one dot but we don't allow it + * here. + * + * @param token The token to examine + * @return Does the token look like a JWT + */ + public boolean isJwt(String token) { + return StringUtils.countMatches(token, SEPARATOR_CHAR) == NUM_SEPARATORS_IN_JWT_TOKEN; + } +} diff --git a/cerberus-web/src/main/resources/cerberus.yaml b/cerberus-web/src/main/resources/cerberus.yaml index 5ef6d1928..14e31818c 100644 --- a/cerberus-web/src/main/resources/cerberus.yaml +++ b/cerberus-web/src/main/resources/cerberus.yaml @@ -138,6 +138,14 @@ cerberus: # clientId: yourClientId # clientSecret: yourClientSecret # subdomain: yourSubDomain + auth: + token: + issue-type: session # Can be session or jwt + accept-type: session # Can be session, jwt or all (which is both) +# auth.jwt: +# secret.local.autoGenerate: false # generate jwt secret for local development +# secret.local.enabled: false # set this to true when doing local development +# secret.bucket: jwt-secret-bucket # s3 bucket containing secret material # With Cerberus 4.+ (Phoenix) We now have officially deprecated and turned off by default KMS Auth @@ -232,6 +240,21 @@ cerberus: # Every hour cronExpression: "0 0 * ? * *" + # JWT + jwtSecretRefreshJob: + enabled: false + # Every minute + cronExpression: "0 * * ? * *" + + jwtBlocklistCleanUpJob: + enabled: false + # Every 5 minutes + cronExpression: "30 0/5 * ? * *" + + jwtBlocklistRefreshJob: + enabled: false + # Every 10 seconds + cronExpression: "0/10 * * ? * *" ################################################################################################ # This Job require auth.iam.kms.rootUserArn,adminRoleArn,cmsRoleArn to be configured diff --git a/cerberus-web/src/main/resources/com/nike/cerberus/mapper/JwtBlocklistMapper.xml b/cerberus-web/src/main/resources/com/nike/cerberus/mapper/JwtBlocklistMapper.xml new file mode 100644 index 000000000..c85f975fd --- /dev/null +++ b/cerberus-web/src/main/resources/com/nike/cerberus/mapper/JwtBlocklistMapper.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + INSERT INTO JWT_BLOCKLIST ( + ID, + EXPIRES_TS + ) + VALUES ( + #{record.id}, + #{record.expiresTs} + ) + + + + DELETE FROM JWT_BLOCKLIST WHERE EXPIRES_TS < CURRENT_TIME + + \ No newline at end of file diff --git a/cerberus-web/src/main/resources/com/nike/cerberus/migration/V1.7.0.0__jwt_blocklist.sql b/cerberus-web/src/main/resources/com/nike/cerberus/migration/V1.7.0.0__jwt_blocklist.sql new file mode 100644 index 000000000..f4f160566 --- /dev/null +++ b/cerberus-web/src/main/resources/com/nike/cerberus/migration/V1.7.0.0__jwt_blocklist.sql @@ -0,0 +1,15 @@ +### +# +# Create Table for JWT Blocklist +# +### + +CREATE TABLE JWT_BLOCKLIST( + ID CHAR(36) NOT NULL, + EXPIRES_TS DATETIME NOT NULL, + PRIMARY KEY (ID) +) + ENGINE = InnoDB DEFAULT CHARSET = utf8; + +ALTER TABLE JWT_BLOCKLIST + ADD INDEX `IX_JWT_BLOCKLIST_EXPIRES_TS` (EXPIRES_TS); \ No newline at end of file diff --git a/cerberus-web/src/test/java/com/nike/cerberus/jwt/CerberusSigningKeyResolverTest.java b/cerberus-web/src/test/java/com/nike/cerberus/jwt/CerberusSigningKeyResolverTest.java new file mode 100644 index 000000000..c5b6e429f --- /dev/null +++ b/cerberus-web/src/test/java/com/nike/cerberus/jwt/CerberusSigningKeyResolverTest.java @@ -0,0 +1,167 @@ +package com.nike.cerberus.jwt; + +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nike.cerberus.service.ConfigService; +import com.nike.cerberus.util.UuidSupplier; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.impl.DefaultClaims; +import io.jsonwebtoken.impl.DefaultJwsHeader; +import java.security.Key; +import java.util.LinkedList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +public class CerberusSigningKeyResolverTest { + @Mock + private CerberusSigningKeyResolver.JwtServiceOptionalPropertyHolder + jwtServiceOptionalPropertyHolder; + + @Mock private ConfigService configService; + + @Mock private ObjectMapper objectMapper; + + @Mock private UuidSupplier uuidSupplier; + + private CerberusSigningKeyResolver cerberusSigningKeyResolver; + + private JwtSecretData jwtSecretData = new JwtSecretData(); + + private String configStoreJwtSecretData; + + @Before + public void setUp() throws Exception { + initMocks(this); + configStoreJwtSecretData = "foo"; + LinkedList jwtSecrets = new LinkedList<>(); + jwtSecretData.setJwtSecrets(jwtSecrets); + + JwtSecret jwtSecret1 = new JwtSecret(); + jwtSecret1.setCreatedTs(100); + jwtSecret1.setEffectiveTs(200); + jwtSecret1.setId("key id 1"); + jwtSecret1.setSecret( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="); + jwtSecrets.add(jwtSecret1); + + JwtSecret jwtSecret2 = new JwtSecret(); + jwtSecret2.setCreatedTs(300); + jwtSecret2.setEffectiveTs(400); + jwtSecret2.setId("key id 2"); + jwtSecret2.setSecret( + "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="); + jwtSecrets.add(jwtSecret2); + + JwtSecret jwtSecret3 = new JwtSecret(); + jwtSecret3.setCreatedTs(500); + jwtSecret3.setEffectiveTs(600); + jwtSecret3.setId("key id 3"); + jwtSecret3.setSecret( + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="); + jwtSecrets.add(jwtSecret3); + + when(objectMapper.readValue(anyString(), same(JwtSecretData.class))).thenReturn(jwtSecretData); + when(configService.getJwtSecrets()).thenReturn(configStoreJwtSecretData); + + cerberusSigningKeyResolver = + new CerberusSigningKeyResolver( + jwtServiceOptionalPropertyHolder, + objectMapper, + java.util.Optional.of(configService), + false, + false, + uuidSupplier); + } + + @Test + public void test_get_future_jwt_secrets() { + List futureJwtSecrets = + cerberusSigningKeyResolver.getFutureJwtSecrets(jwtSecretData, 300); + assertEquals(2, futureJwtSecrets.size()); + assertEquals("key id 2", futureJwtSecrets.get(0).getId()); + } + + @Test + public void test_get_current_key_id() { + String keyId = cerberusSigningKeyResolver.getSigningKeyId(jwtSecretData, 700); + assertEquals("key id 3", keyId); + } + + @Test + public void test_get_current_key_id_with_future_key() { + String keyId = cerberusSigningKeyResolver.getSigningKeyId(jwtSecretData, 500); + assertEquals("key id 2", keyId); + } + + @Test(expected = IllegalArgumentException.class) + public void test_refresh_with_weak_key_should_throw_exception() { + LinkedList jwtSecrets = + cerberusSigningKeyResolver.getJwtSecretData().getJwtSecrets(); + JwtSecret jwtSecret = new JwtSecret(); + jwtSecret.setCreatedTs(500); + jwtSecret.setEffectiveTs(600); + jwtSecret.setId("key id weak"); + jwtSecret.setSecret("AAA=="); + jwtSecrets.add(jwtSecret); + + cerberusSigningKeyResolver.refresh(); + } + + @Test(expected = IllegalArgumentException.class) + public void test_refresh_with_empty_kid_should_throw_exception() { + LinkedList jwtSecrets = + cerberusSigningKeyResolver.getJwtSecretData().getJwtSecrets(); + JwtSecret jwtSecret = new JwtSecret(); + jwtSecret.setCreatedTs(500); + jwtSecret.setEffectiveTs(600); + jwtSecret.setId(""); + jwtSecret.setSecret( + "DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="); + jwtSecrets.add(jwtSecret); + + cerberusSigningKeyResolver.refresh(); + } + + @Test + public void test_resolve_signing_key_returns_newest_key() { + CerberusJwtKeySpec keySpec = cerberusSigningKeyResolver.resolveSigningKey(); + assertEquals("key id 3", keySpec.getKid()); + assertEquals("HmacSHA512", keySpec.getAlgorithm()); + assertEquals(8, keySpec.getEncoded()[0]); // 8 == "CA" + } + + @Test + public void test_resolve_signing_key_returns_correct_key() { + JwsHeader jwsHeader = new DefaultJwsHeader(); + jwsHeader.setAlgorithm("HS512"); + jwsHeader.setKeyId("key id 2"); + Claims claims = new DefaultClaims(); + Key key = cerberusSigningKeyResolver.resolveSigningKey(jwsHeader, claims); + assertEquals(key.getAlgorithm(), "HmacSHA512"); + assertEquals(4, key.getEncoded()[0]); // 4 == "BA" + } + + @Test(expected = IllegalArgumentException.class) + public void test_resolve_signing_key_throws_error_on_invalid_key_id() { + JwsHeader jwsHeader = new DefaultJwsHeader(); + jwsHeader.setAlgorithm("HS512"); + jwsHeader.setKeyId("key id 666"); + Claims claims = new DefaultClaims(); + cerberusSigningKeyResolver.resolveSigningKey(jwsHeader, claims); + } + + @Test(expected = IllegalArgumentException.class) + public void test_resolve_signing_key_throws_error_on_invalid_algorithm() { + JwsHeader jwsHeader = new DefaultJwsHeader(); + jwsHeader.setAlgorithm("none"); + Claims claims = new DefaultClaims(); + cerberusSigningKeyResolver.resolveSigningKey(jwsHeader, claims); + } +} diff --git a/cerberus-web/src/test/java/com/nike/cerberus/record/RecordPojoTest.java b/cerberus-web/src/test/java/com/nike/cerberus/record/RecordPojoTest.java index 8b82eb7a5..b322db9be 100644 --- a/cerberus-web/src/test/java/com/nike/cerberus/record/RecordPojoTest.java +++ b/cerberus-web/src/test/java/com/nike/cerberus/record/RecordPojoTest.java @@ -35,7 +35,7 @@ public void test_pojo_structure_and_behavior() { List pojoClasses = PojoClassFactory.getPojoClasses("com.nike.cerberus.record"); - Assert.assertEquals(15, pojoClasses.size()); + Assert.assertEquals(16, pojoClasses.size()); Validator validator = ValidatorBuilder.create() diff --git a/cerberus-web/src/test/java/com/nike/cerberus/service/AuthTokenServiceTest.java b/cerberus-web/src/test/java/com/nike/cerberus/service/AuthTokenServiceTest.java index b943a915f..3774400f8 100644 --- a/cerberus-web/src/test/java/com/nike/cerberus/service/AuthTokenServiceTest.java +++ b/cerberus-web/src/test/java/com/nike/cerberus/service/AuthTokenServiceTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Nike, inc. + * Copyright (c) 2021 Nike, inc. * * Licensed under the Apache License, Version 2.0 (the "License") * you may not use this file except in compliance with the License. @@ -18,15 +18,22 @@ import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertTrue; +import static org.mockito.Matchers.any; import static org.mockito.Matchers.argThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; +import com.nike.backstopper.exception.ApiException; import com.nike.cerberus.PrincipalType; import com.nike.cerberus.dao.AuthTokenDao; +import com.nike.cerberus.domain.AuthTokenAcceptType; +import com.nike.cerberus.domain.AuthTokenIssueType; import com.nike.cerberus.domain.CerberusAuthToken; +import com.nike.cerberus.error.AuthTokenTooLongException; +import com.nike.cerberus.jwt.CerberusJwtClaims; import com.nike.cerberus.record.AuthTokenRecord; +import com.nike.cerberus.security.CerberusPrincipal; import com.nike.cerberus.util.AuthTokenGenerator; import com.nike.cerberus.util.DateTimeSupplier; import com.nike.cerberus.util.TokenHasher; @@ -47,6 +54,9 @@ public class AuthTokenServiceTest { @Mock private AuthTokenGenerator authTokenGenerator; @Mock private AuthTokenDao authTokenDao; @Mock private DateTimeSupplier dateTimeSupplier; + @Mock private JwtService jwtService; + @Mock private JwtFeatureFlags tokenFlag; + @Mock private CerberusPrincipal cerberusPrincipal; AuthTokenService authTokenService; @@ -56,7 +66,15 @@ public void before() { authTokenService = new AuthTokenService( - uuidSupplier, tokenHasher, authTokenGenerator, authTokenDao, dateTimeSupplier); + uuidSupplier, + tokenHasher, + authTokenGenerator, + authTokenDao, + dateTimeSupplier, + jwtService, + tokenFlag); + + when(tokenFlag.getAcceptType()).thenReturn(AuthTokenAcceptType.ALL); } @Test @@ -68,8 +86,9 @@ public void before() { final String fakeHash = "kjadlkfjasdlkf;jlkj1243asdfasdf"; String principal = "test-user@domain.com"; String groups = "group1,group2,group3"; - + when(tokenFlag.getIssueType()).thenReturn(AuthTokenIssueType.SESSION); when(uuidSupplier.get()).thenReturn(id); + when(authTokenGenerator.generateSecureToken()).thenReturn(expectedTokenId); when(dateTimeSupplier.get()).thenReturn(now); when(tokenHasher.hashToken(expectedTokenId)).thenReturn(fakeHash); @@ -105,9 +124,44 @@ public boolean matches(Object argument) { } @Test - public void test_that_getCerberusAuthToken_returns_emtpy_if_token_not_present() { + public void test_that_generateToken_attempts_to_write_a_jwt_and_returns_proper_object() + throws AuthTokenTooLongException { + String id = UUID.randomUUID().toString(); + String expectedTokenId = "abc-123-def-456"; + OffsetDateTime now = OffsetDateTime.now(); + String principal = "test-user@domain.com"; + String groups = "group1,group2,group3"; + when(tokenFlag.getIssueType()).thenReturn(AuthTokenIssueType.JWT); + when(uuidSupplier.get()).thenReturn(id); + when(jwtService.generateJwtToken(any())).thenReturn(expectedTokenId); + + when(dateTimeSupplier.get()).thenReturn(now); + + CerberusAuthToken token = + authTokenService.generateToken(principal, PrincipalType.USER, false, groups, 5, 0); + + assertEquals( + "The token should have the un-hashed value returned", expectedTokenId, token.getToken()); + assertEquals("The token should have a created date of now", now, token.getCreated()); + assertEquals( + "The token should expire ttl minutes after now", now.plusMinutes(5), token.getExpires()); + assertEquals("The token should have the proper principal", principal, token.getPrincipal()); + assertEquals( + "The token should be the principal type that was passed in", + PrincipalType.USER, + token.getPrincipalType()); + assertEquals("The token should not have access to admin endpoints", false, token.isAdmin()); + assertEquals( + "The token should have the groups that where passed in", groups, token.getGroups()); + assertEquals( + "The newly created token should have a refresh count of 0", 0, token.getRefreshCount()); + } + + @Test + public void test_that_getCerberusAuthToken_returns_empty_if_token_not_present() { final String tokenId = "abc-123-def-456"; final String fakeHash = "kjadlkfjasdlkf;jlkj1243asdfasdf"; + when(tokenHasher.hashToken(tokenId)).thenReturn(fakeHash); when(authTokenDao.getAuthTokenFromHash(fakeHash)).thenReturn(Optional.empty()); @@ -116,9 +170,35 @@ public void test_that_getCerberusAuthToken_returns_emtpy_if_token_not_present() } @Test - public void test_that_when_a_token_is_expired_empty_is_returned() { + public void test_that_getCerberusAuthToken_returns_empty_if_JWT_not_present() { + final String tokenId = "abc.123.def"; + + when(jwtService.isJwt(tokenId)).thenReturn(true); + when(jwtService.parseAndValidateToken(tokenId)).thenReturn(Optional.empty()); + + Optional tokenOptional = authTokenService.getCerberusAuthToken(tokenId); + assertTrue("optional should be empty", !tokenOptional.isPresent()); + } + + @Test(expected = ApiException.class) + public void test_that_auth_token_too_long_error_is_caught_correctly_for_JWT() + throws AuthTokenTooLongException { + String principal = "test-user@domain.com"; + String groups = "group1,group2,group3"; + OffsetDateTime now = OffsetDateTime.now(); + when(dateTimeSupplier.get()).thenReturn(now); + when(tokenFlag.getIssueType()).thenReturn(AuthTokenIssueType.JWT); + when(tokenFlag.getAcceptType()).thenReturn(AuthTokenAcceptType.JWT); + when(jwtService.generateJwtToken(any())) + .thenThrow(new AuthTokenTooLongException("auth token too long")); + authTokenService.generateToken(principal, PrincipalType.USER, false, groups, 5, 0); + } + + @Test + public void test_that_when_a_token_is_expired_empty_is_returned_session() { final String tokenId = "abc-123-def-456"; final String fakeHash = "kjadlkfjasdlkf;jlkj1243asdfasdf"; + when(tokenHasher.hashToken(tokenId)).thenReturn(fakeHash); when(authTokenDao.getAuthTokenFromHash(fakeHash)) .thenReturn( @@ -128,9 +208,22 @@ public void test_that_when_a_token_is_expired_empty_is_returned() { assertTrue("optional should be empty", !tokenOptional.isPresent()); } + @Test + public void test_that_when_a_token_is_expired_empty_is_returned_jwt() { + final String tokenId = "abc.123.def"; + + when(jwtService.isJwt(tokenId)).thenReturn(true); + when(jwtService.parseAndValidateToken(tokenId)) + .thenReturn( + Optional.of(new CerberusJwtClaims().setExpiresTs(OffsetDateTime.now().minusHours(1)))); + + Optional tokenOptional = authTokenService.getCerberusAuthToken(tokenId); + assertTrue("optional should be empty", !tokenOptional.isPresent()); + } + @Test public void - test_that_when_a_valid_non_expired_token_record_is_present_the_optional_is_populated_with_valid_token_object() { + test_that_when_a_valid_non_expired_token_record_is_present_the_optional_is_populated_with_valid_token_object_session() { String id = UUID.randomUUID().toString(); String tokenId = "abc-123-def-456"; OffsetDateTime now = OffsetDateTime.now(); @@ -167,16 +260,66 @@ public void test_that_when_a_token_is_expired_empty_is_returned() { assertEquals(0, token.getRefreshCount()); } + @Test + public void + test_that_when_a_valid_non_expired_token_record_is_present_the_optional_is_populated_with_valid_token_object_jwt() { + String id = UUID.randomUUID().toString(); + String tokenId = "abc.123.def"; + OffsetDateTime now = OffsetDateTime.now(); + String principal = "test-user@domain.com"; + String groups = "group1,group2,group3"; + + when(jwtService.isJwt(tokenId)).thenReturn(true); + when(jwtService.parseAndValidateToken(tokenId)) + .thenReturn( + Optional.of( + new CerberusJwtClaims() + .setId(id) + .setCreatedTs(now) + .setExpiresTs(now.plusHours(1)) + .setPrincipal(principal) + .setPrincipalType(PrincipalType.USER.getName()) + .setIsAdmin(false) + .setGroups(groups) + .setRefreshCount(0))); + + Optional tokenOptional = authTokenService.getCerberusAuthToken(tokenId); + + CerberusAuthToken token = + tokenOptional.orElseThrow(() -> new AssertionFailedError("Token should be present")); + assertEquals(tokenId, token.getToken()); + assertEquals(now, token.getCreated()); + assertEquals(now.plusHours(1), token.getExpires()); + assertEquals(principal, token.getPrincipal()); + assertEquals(PrincipalType.USER, token.getPrincipalType()); + assertEquals(false, token.isAdmin()); + assertEquals(groups, token.getGroups()); + assertEquals(0, token.getRefreshCount()); + } + @Test public void test_that_revokeToken_calls_the_dao_with_the_hashed_token() { final String tokenId = "abc-123-def-456"; final String fakeHash = "kjadlkfjasdlkf;jlkj1243asdfasdf"; + OffsetDateTime now = OffsetDateTime.now(); when(tokenHasher.hashToken(tokenId)).thenReturn(fakeHash); - - authTokenService.revokeToken(tokenId); + when(cerberusPrincipal.getToken()).thenReturn(tokenId); + authTokenService.revokeToken(cerberusPrincipal, now); verify(authTokenDao).deleteAuthTokenFromHash(fakeHash); } + @Test + public void test_that_revokeToken_calls_the_jwt_revoke_token() { + String tokenId = "abcdef"; + String token = "abc.123.def"; + OffsetDateTime now = OffsetDateTime.now(); + when(jwtService.isJwt(token)).thenReturn(true); + when(cerberusPrincipal.getTokenId()).thenReturn(tokenId); + when(cerberusPrincipal.getToken()).thenReturn(token); + authTokenService.revokeToken(cerberusPrincipal, now); + verify(jwtService).revokeToken(tokenId, now); + } + @Test public void test_that_deleteExpiredTokens_directly_proxies_dao() { int maxDelete = 1; diff --git a/cerberus-web/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java b/cerberus-web/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java index 11bfe73ae..0b14df4c4 100644 --- a/cerberus-web/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java +++ b/cerberus-web/src/test/java/com/nike/cerberus/service/AuthenticationServiceTest.java @@ -431,12 +431,12 @@ public void tests_that_refreshUserToken_refreshes_token_when_count_is_less_than_ Integer curCount = MAX_LIMIT - 1; CerberusAuthToken authToken = - CerberusAuthToken.builder() - .principalType(PrincipalType.USER) - .principal("principal") - .groups("group1,group2") - .refreshCount(curCount) - .token(UUID.randomUUID().toString()) + CerberusAuthToken.Builder.create() + .withPrincipalType(PrincipalType.USER) + .withPrincipal("principal") + .withGroups("group1,group2") + .withRefreshCount(curCount) + .withToken(UUID.randomUUID().toString()) .build(); CerberusPrincipal principal = new CerberusPrincipal(authToken); @@ -445,14 +445,14 @@ public void tests_that_refreshUserToken_refreshes_token_when_count_is_less_than_ when(authTokenService.generateToken( anyString(), any(PrincipalType.class), anyBoolean(), anyString(), anyInt(), anyInt())) .thenReturn( - CerberusAuthToken.builder() - .principalType(PrincipalType.USER) - .principal("principal") - .groups("group1,group2") - .refreshCount(curCount + 1) - .token(UUID.randomUUID().toString()) - .created(now) - .expires(now.plusHours(1)) + CerberusAuthToken.Builder.create() + .withPrincipalType(PrincipalType.USER) + .withPrincipal("principal") + .withGroups("group1,group2") + .withRefreshCount(curCount + 1) + .withToken(UUID.randomUUID().toString()) + .withCreated(now) + .withExpires(now.plusHours(1)) .build()); AuthResponse response = authenticationService.refreshUserToken(principal); diff --git a/cerberus-web/src/test/java/com/nike/cerberus/service/JwtServiceTest.java b/cerberus-web/src/test/java/com/nike/cerberus/service/JwtServiceTest.java new file mode 100644 index 000000000..38271fb3a --- /dev/null +++ b/cerberus-web/src/test/java/com/nike/cerberus/service/JwtServiceTest.java @@ -0,0 +1,121 @@ +package com.nike.cerberus.service; + +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.common.collect.Sets; +import com.nike.cerberus.dao.JwtBlocklistDao; +import com.nike.cerberus.error.AuthTokenTooLongException; +import com.nike.cerberus.jwt.CerberusJwtClaims; +import com.nike.cerberus.jwt.CerberusJwtKeySpec; +import com.nike.cerberus.jwt.CerberusSigningKeyResolver; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +public class JwtServiceTest { + + @Mock private CerberusSigningKeyResolver signingKeyResolver; + + @Mock private JwtBlocklistDao jwtBlocklistDao; + + private JwtService jwtService; + + private CerberusJwtKeySpec cerberusJwtKeySpec; + + private CerberusJwtClaims cerberusJwtClaims; + + @Before + public void setUp() throws Exception { + initMocks(this); + jwtService = new JwtService(signingKeyResolver, "local", jwtBlocklistDao); + ReflectionTestUtils.setField(jwtService, "maxTokenLength", 1600); + cerberusJwtKeySpec = new CerberusJwtKeySpec(new byte[64], "HmacSHA512", "key id"); + cerberusJwtClaims = new CerberusJwtClaims(); + cerberusJwtClaims.setId("id"); + cerberusJwtClaims.setPrincipal("principal"); + cerberusJwtClaims.setGroups("groups"); + cerberusJwtClaims.setIsAdmin(true); + cerberusJwtClaims.setPrincipalType("type"); + cerberusJwtClaims.setRefreshCount(1); + cerberusJwtClaims.setCreatedTs(OffsetDateTime.of(2000, 1, 1, 1, 1, 1, 1, ZoneOffset.UTC)); + cerberusJwtClaims.setExpiresTs( + OffsetDateTime.of(3000, 1, 1, 1, 1, 1, 1, ZoneOffset.UTC)); // should be good for a while + + when(signingKeyResolver.resolveSigningKey()).thenReturn(cerberusJwtKeySpec); + when(signingKeyResolver.resolveSigningKey(any(JwsHeader.class), any(Claims.class))) + .thenReturn(cerberusJwtKeySpec); + } + + @Test + public void test_generate_jwt_token_parse_and_validate_claim() throws AuthTokenTooLongException { + String token = jwtService.generateJwtToken(cerberusJwtClaims); + assertEquals(3, token.split("\\.").length); + Optional cerberusJwtClaimsOptional = jwtService.parseAndValidateToken(token); + assertTrue(cerberusJwtClaimsOptional.isPresent()); + CerberusJwtClaims cerberusJwtClaims = cerberusJwtClaimsOptional.get(); + + assertEquals("id", cerberusJwtClaims.getId()); + assertEquals("principal", cerberusJwtClaims.getPrincipal()); + assertEquals("groups", cerberusJwtClaims.getGroups()); + assertEquals(true, cerberusJwtClaims.getIsAdmin()); + assertEquals("type", cerberusJwtClaims.getPrincipalType()); + assertEquals(1, (long) cerberusJwtClaims.getRefreshCount()); + assertEquals( + OffsetDateTime.of(2000, 1, 1, 1, 1, 1, 1, ZoneOffset.UTC).toEpochSecond(), + cerberusJwtClaims.getCreatedTs().toEpochSecond()); + assertEquals( + OffsetDateTime.of(3000, 1, 1, 1, 1, 1, 1, ZoneOffset.UTC).toEpochSecond(), + cerberusJwtClaims.getExpiresTs().toEpochSecond()); + } + + @Test + public void test_expired_token_returns_empty() throws AuthTokenTooLongException { + cerberusJwtClaims.setExpiresTs(OffsetDateTime.of(2000, 1, 1, 1, 1, 1, 1, ZoneOffset.UTC)); + String token = jwtService.generateJwtToken(cerberusJwtClaims); + Optional cerberusJwtClaims = jwtService.parseAndValidateToken(token); + assertFalse(cerberusJwtClaims.isPresent()); + } + + @Test + public void test_unsigned_token_returns_empty() { + String token = + "eyJraWQiOiJrZXkgaWQiLCJhbGciOiJIUzUxMiJ9.eyJqdGkiOiJpZCIsImlzcyI6ImxvY2FsIiwic3ViIjoicHJpbm" + + "NpcGFsIiwicHJpbmNpcGFsVHlwZSI6InR5cGUiLCJncm91cHMiOiJncm91cHMiLCJpc0FkbWluIjp0cnVlLCJyZWZyZXNoQ291" + + "bnQiOjEsImV4cCI6NDA3MDkxMjQ2MSwiaWF0Ijo5NDY2ODg0NjF9"; + Optional cerberusJwtClaims = jwtService.parseAndValidateToken(token); + assertFalse(cerberusJwtClaims.isPresent()); + } + + @Test(expected = AuthTokenTooLongException.class) + public void test_that_auth_token_too_long_error_is_thrown_correctly_for_JWT() + throws AuthTokenTooLongException { + ReflectionTestUtils.setField(jwtService, "maxTokenLength", 0); + jwtService.generateJwtToken(cerberusJwtClaims); + } + + @Test + public void test_parseAndValidateToken_returns_empty_for_blocklisted_token() + throws AuthTokenTooLongException { + String token = jwtService.generateJwtToken(cerberusJwtClaims); + when(jwtBlocklistDao.getBlocklist()).thenReturn(Sets.newHashSet("id")); + jwtService.refreshBlocklist(); + Optional cerberusJwtClaims = jwtService.parseAndValidateToken(token); + assertFalse(cerberusJwtClaims.isPresent()); + } + + @Test + public void test_that_revokeToken_calls_the_dao() { + final String tokenId = "abc-123-def-456"; + jwtService.revokeToken(tokenId, OffsetDateTime.now()); + verify(jwtBlocklistDao, times(1)).addToBlocklist(any()); + } +} diff --git a/dependency-check-supressions.xml b/dependency-check-supressions.xml index 17d7f43a9..23fc7bcb8 100644 --- a/dependency-check-supressions.xml +++ b/dependency-check-supressions.xml @@ -137,4 +137,11 @@ ^pkg:npm/faye\-websocket@.*$ CVE-2020-15133 + + + ^pkg:npm/postcss@.*$ + CVE-2021-23368 + diff --git a/gradle.properties b/gradle.properties index da5116cbe..c864b7a24 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,5 +14,5 @@ # limitations under the License. # -version=4.11.0 +version=4.12.0 group=com.nike.cerberus