CVE-2022-39308 (published 2022-10-14T19:15Z)

GoCD is a continuous delivery server. GoCD helps you automate and streamline the build-test-release cycle for continuous delivery of your product. GoCD versions from 19.2.0 to 19.10.0 (inclusive) are subject to a timing attack in validation of access tokens due to use of regular string comparison for validation of the token rather than a constant time algorithm. This could allow a brute force attack on GoCD server API calls to observe timing differences in validations in order to guess an access token generated by a user for API access. This issue is fixed in GoCD version 19.11.0. As a workaround, users can apply rate limiting or insert random delays to API calls made to GoCD Server via a reverse proxy or other fronting web server. Another workaround, users may disallow use of access tokens by users by having an administrator revoke all access tokens through the "Access Token Management" admin function.

CVSS v3 base score: 5.9, MEDIUM (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-697
cwe_name: Incorrect Comparison
description: The product compares two entities in a security-relevant context, but the comparison is incorrect, which may lead to resultant weaknesses.
extended_description: 
            This Pillar covers several possibilities:
               
                  the comparison checks one factor incorrectly;
                  the comparison should consider multiple factors, but it does not check at least one of those factors at all;
                  the comparison checks the wrong factor.
               
         
         

Fix information:
repo_url: https://github.com/gocd/gocd
commit_details: repo_url: https://github.com/gocd/gocd
author: Ganesh S Patil
author_date: 2019-02-01 11:38:30+05:30
msg: Access Token APIs  (#5797)

* Define a AuthTokenSqlMapDao to store and retrieve token information from DB

* Introduce Auth Token API to manage auth tokens

* Store the hashed token instead of storing the raw value

* Some tests

* Store information of the user who has created the auth token

* Use SHA-256 instead of MD5 for hashing token value

* Change username data type from VARCHAR to VARCHAR_IGNORECASE

* Introduce AuthTokenMother to create authToken in tests

* Fix auth token controller specs

* Remove unused class variable from test

* Introduce auth token index endpoint to fetch all the tokens belonging to me

* Add extra attributes to auth token
* Salt Id
* Salt Value
* Auth Config Id
* Revoked At

* Add support for revoking access tokens
Add a method to validate and fetch access token object provided access token

* Rename controller package and module

* rename service and dao

* Rename auth token package

* Rename rules under url rewrite

* Renamed few more occurences of auth token to acccess tokens
repository: repo_url: https://github.com/gocd/gocd
repo_name: gocd/gocd
description: GoCD - Continuous Delivery server main repository
repo_language: Java
owner: gocd
file_changes: filename: .gitignore
change_type: ModificationType.MODIFY
diff: @@ -93,6 +93,9 @@ api/api-admins-config-v2/logs/
 api/api-admins-config-v2/out/
 api/api-admins-config-v2/target/
 api/api-artifact-store-config-v1/config/
+api/api-access-token-v1/logs/
+api/api-access-token-v1/out/
+api/api-access-token-v1/target/
 api/api-artifact-store-config-v1/logs/
 api/api-artifact-store-config-v1/out/
 api/api-artifact-store-config-v1/target/

programming_language: Batchfile
filename: build.gradle
change_type: ModificationType.ADD
diff: @@ -0,0 +1,27 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.
+ */
+
+apply plugin: 'jacoco'
+apply plugin: 'groovy'
+
+dependencies {
+  compile project(':api:api-base')
+
+  testCompile project(path: ':api:api-base', configuration: 'testOutput')
+
+  testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: project.versions.junit5
+  testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: project.versions.junit5
+}

programming_language: JavaScript
filename: AccessTokenControllerV1.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,135 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.apiv1.accessToken;
+
+import com.thoughtworks.go.api.ApiController;
+import com.thoughtworks.go.api.ApiVersion;
+import com.thoughtworks.go.api.representers.JsonReader;
+import com.thoughtworks.go.api.spring.ApiAuthenticationHelper;
+import com.thoughtworks.go.api.util.GsonTransformer;
+import com.thoughtworks.go.apiv1.accessToken.representers.AccessTokenRepresenter;
+import com.thoughtworks.go.apiv1.accessToken.representers.AccessTokensRepresenter;
+import com.thoughtworks.go.config.exceptions.RecordNotFoundException;
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.server.newsecurity.models.AuthenticationToken;
+import com.thoughtworks.go.server.newsecurity.utils.SessionUtils;
+import com.thoughtworks.go.server.service.AccessTokenService;
+import com.thoughtworks.go.server.service.result.HttpLocalizedOperationResult;
+import com.thoughtworks.go.spark.Routes;
+import com.thoughtworks.go.spark.spring.SparkSpringController;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import spark.Request;
+import spark.Response;
+
+import java.io.IOException;
+import java.util.List;
+
+import static spark.Spark.*;
+
+@Component
+public class AccessTokenControllerV1 extends ApiController implements SparkSpringController {
+
+    private final ApiAuthenticationHelper apiAuthenticationHelper;
+    private AccessTokenService accessTokenService;
+
+    @Autowired
+    public AccessTokenControllerV1(ApiAuthenticationHelper apiAuthenticationHelper, AccessTokenService AccessTokenService) {
+        super(ApiVersion.v1);
+        this.apiAuthenticationHelper = apiAuthenticationHelper;
+        this.accessTokenService = AccessTokenService;
+    }
+
+    @Override
+    public String controllerBasePath() {
+        return Routes.AccessToken.BASE;
+    }
+
+    @Override
+    public void setupRoutes() {
+        path(controllerBasePath(), () -> {
+            before("", mimeType, this::setContentType);
+            before("/*", mimeType, this::setContentType);
+
+            before("", mimeType, this.apiAuthenticationHelper::checkUserAnd403);
+            before("/*", mimeType, this.apiAuthenticationHelper::checkUserAnd403);
+
+            get("", mimeType, this::getAllAccessTokens);
+            post("", mimeType, this::createAccessToken);
+            patch(String.format("%s%s/revoke", Routes.AccessToken.USERNAME, Routes.AccessToken.TOKEN_NAME), mimeType, this::revokeAccessToken);
+            get(Routes.AccessToken.TOKEN_NAME, mimeType, this::getAccessToken);
+
+            exception(RecordNotFoundException.class, this::notFound);
+        });
+    }
+
+    public String createAccessToken(Request request, Response response) throws Exception {
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        final JsonReader reader = GsonTransformer.getInstance().jsonReaderFrom(request.body());
+
+        String tokenName = reader.getString("name");
+        String tokenDescription = reader.optString("description").orElse(null);
+
+        AccessToken created = accessTokenService.create(tokenName, tokenDescription, currentUsername(), currentUserAuthConfigId(request), result);
+
+        if (result.isSuccessful()) {
+            return renderAccessToken(request, response, created, true);
+        }
+
+        return renderHTTPOperationResult(result, request, response);
+    }
+
+    public String getAccessToken(Request request, Response response) throws Exception {
+        final AccessToken token = accessTokenService.find(request.params("token_name"), currentUsername().getUsername().toString());
+
+        if (token == null) {
+            throw new RecordNotFoundException();
+        }
+
+        return renderAccessToken(request, response, token, false);
+    }
+
+    public String getAllAccessTokens(Request request, Response response) throws Exception {
+        List<AccessToken> allTokens = accessTokenService.findAllTokensForUser(currentUsername());
+        return writerForTopLevelObject(request, response, outputWriter -> AccessTokensRepresenter.toJSON(outputWriter, allTokens));
+    }
+
+    public String revokeAccessToken(Request request, Response response) throws Exception {
+        String tokenName = request.params("token_name");
+        String username = request.params("username");
+
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+        accessTokenService.revokeAccessToken(tokenName, username, result);
+
+        if (result.isSuccessful()) {
+            return renderAccessToken(request, response, accessTokenService.find(tokenName, username), true);
+        }
+
+        return renderHTTPOperationResult(result, request, response);
+    }
+
+    private String renderAccessToken(Request request, Response response, AccessToken token, boolean includeTokenValue) throws IOException {
+        return writerForTopLevelObject(request, response, outputWriter -> AccessTokenRepresenter.toJSON(outputWriter, token, includeTokenValue));
+    }
+
+    private String currentUserAuthConfigId(Request request) {
+        AuthenticationToken<?> authenticationToken = SessionUtils.getAuthenticationToken(request.raw());
+        return authenticationToken.getAuthConfigId();
+    }
+
+}

programming_language: Java
filename: AccessTokenRepresenter.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,44 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.apiv1.accessToken.representers;
+
+import com.thoughtworks.go.api.base.OutputWriter;
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.spark.Routes;
+
+public class AccessTokenRepresenter {
+    public static void toJSON(OutputWriter outputWriter, AccessToken token, boolean includeTokenValue) {
+        outputWriter.addLinks(linksWriter -> linksWriter
+                .addLink("self", Routes.AccessToken.name(token.getName()))
+                .addAbsoluteLink("doc", Routes.AccessToken.DOC)
+                .addLink("find", Routes.AccessToken.find()));
+
+        outputWriter.add("name", token.getName())
+                .add("description", token.getDescription())
+                .add("auth_config_id", token.getAuthConfigId())
+                .addChild("_meta", metaWriter -> {
+                    metaWriter.add("is_revoked", token.isRevoked())
+                            .add("revoked_at", token.getRevokedAt())
+                            .add("created_at", token.getCreatedAt())
+                            .add("last_used_at", token.getLastUsed());
+                });
+
+        if (includeTokenValue) {
+            outputWriter.add("token", token.getOriginalValue());
+        }
+    }
+}

programming_language: Java
filename: AccessTokensRepresenter.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,37 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.apiv1.accessToken.representers;
+
+import com.thoughtworks.go.api.base.OutputWriter;
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.spark.Routes;
+
+import java.util.List;
+
+public class AccessTokensRepresenter {
+    public static void toJSON(OutputWriter outputWriter, List<AccessToken> allTokens) {
+        outputWriter.addLinks(outputLinkWriter -> outputLinkWriter
+                .addLink("self", Routes.AccessToken.BASE)
+                .addAbsoluteLink("doc", Routes.AccessToken.DOC))
+                .addChild("_embedded", embeddedWriter ->
+                        embeddedWriter.addChildList("access_tokens", AccessTokenWriter -> {
+                            allTokens.forEach(token -> AccessTokenWriter.addChild(artifactStoreWriter -> AccessTokenRepresenter.toJSON(artifactStoreWriter, token, false)));
+                        })
+                );
+    }
+}

programming_language: Java
filename: AccessTokenControllerV1Test.groovy
change_type: ModificationType.ADD
diff: @@ -0,0 +1,305 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.apiv1.accessToken
+
+import com.thoughtworks.go.api.SecurityTestTrait
+import com.thoughtworks.go.api.spring.ApiAuthenticationHelper
+import com.thoughtworks.go.api.util.HaltApiMessages
+import com.thoughtworks.go.apiv1.accessToken.representers.AccessTokenRepresenter
+import com.thoughtworks.go.apiv1.accessToken.representers.AccessTokensRepresenter
+import com.thoughtworks.go.server.domain.Username
+import com.thoughtworks.go.server.service.AccessTokenService
+import com.thoughtworks.go.server.service.result.HttpLocalizedOperationResult
+import com.thoughtworks.go.spark.ControllerTrait
+import com.thoughtworks.go.spark.NormalUserSecurity
+import com.thoughtworks.go.spark.SecurityServiceTrait
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.mockito.Mock
+import org.mockito.invocation.InvocationOnMock
+
+import static com.thoughtworks.go.api.base.JsonUtils.toObjectString
+import static com.thoughtworks.go.helper.AccessTokenMother.accessTokenWithName
+import static org.mockito.ArgumentMatchers.any
+import static org.mockito.ArgumentMatchers.eq
+import static org.mockito.Mockito.when
+import static org.mockito.MockitoAnnotations.initMocks
+
+class AccessTokenControllerV1Test implements ControllerTrait<AccessTokenControllerV1>, SecurityServiceTrait {
+  @Mock
+  AccessTokenService accessTokenService
+
+  @BeforeEach
+  void setUp() {
+    initMocks(this)
+  }
+
+  @Override
+  AccessTokenControllerV1 createControllerInstance() {
+    return new AccessTokenControllerV1(new ApiAuthenticationHelper(securityService, goConfigService), accessTokenService)
+  }
+
+  @Nested
+  class Show {
+    def tokenName = "token1"
+    def token = accessTokenWithName(tokenName)
+
+    @Nested
+    class Security implements SecurityTestTrait, NormalUserSecurity {
+      @BeforeEach
+      void setUp() {
+        when(accessTokenService.find(eq(tokenName), any(Username.class))).thenReturn(token)
+      }
+
+      @Override
+      String getControllerMethodUnderTest() {
+        return "getAccessToken"
+      }
+
+      @Override
+      void makeHttpCall() {
+        getWithApiHeader(controller.controllerPath(tokenName))
+      }
+    }
+
+    @Nested
+    class AsAdmin {
+      @BeforeEach
+      void setUp() {
+        enableSecurity()
+        loginAsAdmin()
+
+        when(accessTokenService.find(eq(tokenName), any(String.class))).thenReturn(token)
+      }
+
+      @Test
+      void 'should render the access token'() {
+        getWithApiHeader(controller.controllerPath(tokenName))
+
+        assertThatResponse()
+          .isOk()
+          .hasContentType(controller.mimeType)
+          .hasBody(toObjectString({ AccessTokenRepresenter.toJSON(it, token, false) }))
+      }
+
+      @Test
+      void 'should render not found when the specified access token does not exists'() {
+        when(accessTokenService.find(eq(tokenName), any(String.class))).thenReturn(null)
+
+        getWithApiHeader(controller.controllerPath(tokenName))
+
+        assertThatResponse()
+          .isNotFound()
+          .hasJsonMessage(HaltApiMessages.notFoundMessage())
+          .hasContentType(controller.mimeType)
+      }
+    }
+  }
+
+  @Nested
+  class Create {
+    def authConfigId = 'authConfigId'
+    def tokenName = "token1"
+    def token = accessTokenWithName(tokenName)
+
+    @Nested
+    class Security implements SecurityTestTrait, NormalUserSecurity {
+      @Override
+      String getControllerMethodUnderTest() {
+        return "createAccessToken"
+      }
+
+      @Override
+      void makeHttpCall() {
+        postWithApiHeader(controller.controllerPath(), [:])
+      }
+    }
+
+    @Nested
+    class AsAdmin {
+      @BeforeEach
+      void setUp() {
+        enableSecurity()
+        loginAsAdmin()
+
+        when(accessTokenService.create(eq(tokenName), eq(token.description), any(Username.class), eq(authConfigId), any(HttpLocalizedOperationResult.class))).thenReturn(token)
+      }
+
+      @Test
+      void 'should create a new access token'() {
+        def requestBody = [
+          name       : token.name,
+          description: token.description
+        ]
+
+        postWithApiHeader(controller.controllerPath(), requestBody)
+
+        assertThatResponse()
+          .isOk()
+          .hasContentType(controller.mimeType)
+          .hasBody(toObjectString({ AccessTokenRepresenter.toJSON(it, token, true) }))
+      }
+
+      @Test
+      void 'should create a new access token without providing token description'() {
+        token.setDescription(null)
+        when(accessTokenService.create(eq(tokenName), eq(token.description), any(Username.class), eq(authConfigId), any(HttpLocalizedOperationResult.class))).thenReturn(token)
+
+        def requestBody = [
+          name: token.name
+        ]
+
+        postWithApiHeader(controller.controllerPath(), requestBody)
+
+        assertThatResponse()
+          .isOk()
+          .hasContentType(controller.mimeType)
+          .hasBody(toObjectString({ AccessTokenRepresenter.toJSON(it, token, true) }))
+      }
+
+      @Test
+      void 'should fail to create a new access token when no token name is specified'() {
+        def requestBody = [
+          no_name: token.name
+        ]
+
+        postWithApiHeader(controller.controllerPath(), requestBody)
+
+        assertThatResponse()
+          .isUnprocessableEntity()
+      }
+
+      @Test
+      void 'should show errors occurred while creating a new access token'() {
+        when(accessTokenService.create(eq(tokenName), eq(token.description), any(Username.class), eq(authConfigId), any(HttpLocalizedOperationResult.class))).then({ InvocationOnMock invocation ->
+          HttpLocalizedOperationResult result = invocation.getArguments().last() as HttpLocalizedOperationResult
+          result.unprocessableEntity("Boom!")
+        })
+
+        def requestBody = [
+          name       : token.name,
+          description: token.description
+        ]
+
+        postWithApiHeader(controller.controllerPath(), requestBody)
+
+        assertThatResponse()
+          .isUnprocessableEntity()
+          .hasJsonMessage("Boom!")
+      }
+    }
+  }
+
+  @Nested
+  class Index {
+    def token = accessTokenWithName("token1")
+
+    @Nested
+    class Security implements SecurityTestTrait, NormalUserSecurity {
+      @BeforeEach
+      void setUp() {
+        when(accessTokenService.findAllTokensForUser(any(Username.class))).thenReturn([token])
+      }
+
+      @Override
+      String getControllerMethodUnderTest() {
+        return "getAllAccessTokens"
+      }
+
+      @Override
+      void makeHttpCall() {
+        getWithApiHeader(controller.controllerPath())
+      }
+    }
+
+    @Nested
+    class AsAdmin {
+      @BeforeEach
+      void setUp() {
+        enableSecurity()
+        loginAsAdmin()
+
+        when(accessTokenService.findAllTokensForUser(any(Username.class))).thenReturn([token])
+      }
+
+      @Test
+      void 'should render all the access tokens'() {
+        getWithApiHeader(controller.controllerPath())
+
+        assertThatResponse()
+          .isOk()
+          .hasContentType(controller.mimeType)
+          .hasBody(toObjectString({ AccessTokensRepresenter.toJSON(it, [token]) }))
+      }
+    }
+  }
+
+  @Nested
+  class Revoke {
+    def tokenName = "token1"
+    def userName = "bob"
+    def token = accessTokenWithName(tokenName)
+
+    @Nested
+    class Security implements SecurityTestTrait, NormalUserSecurity {
+      @Override
+      String getControllerMethodUnderTest() {
+        return "revokeAccessToken"
+      }
+
+      @Override
+      void makeHttpCall() {
+        patchWithApiHeader(controller.controllerPath(userName, tokenName, 'revoke'), [:])
+      }
+    }
+
+    @Nested
+    class AsAdmin {
+      @BeforeEach
+      void setUp() {
+        enableSecurity()
+        loginAsAdmin()
+      }
+
+      @Test
+      void 'should revoke the access token'() {
+        when(accessTokenService.find(tokenName, userName)).thenReturn(token)
+        patchWithApiHeader(controller.controllerPath(userName, tokenName, 'revoke'), [:])
+
+        assertThatResponse()
+          .isOk()
+          .hasContentType(controller.mimeType)
+          .hasBody(toObjectString({ AccessTokenRepresenter.toJSON(it, token, true) }))
+      }
+
+      @Test
+      void 'should show errors occurred while revoking a new access token'() {
+        when(accessTokenService.revokeAccessToken(eq(tokenName) as String, eq(userName) as String, any() as HttpLocalizedOperationResult)).then({ InvocationOnMock invocation ->
+          HttpLocalizedOperationResult result = invocation.getArguments().last() as HttpLocalizedOperationResult
+          result.unprocessableEntity("Boom!")
+        })
+
+        patchWithApiHeader(controller.controllerPath(userName, tokenName, 'revoke'), [:])
+
+        assertThatResponse()
+          .isUnprocessableEntity()
+          .hasJsonMessage("Boom!")
+      }
+    }
+  }
+}

programming_language: Scala
filename: AccessTokenRepresenterTest.groovy
change_type: ModificationType.ADD
diff: @@ -0,0 +1,97 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.apiv1.accessToken.representers
+
+
+import org.junit.jupiter.api.Test
+
+import static com.thoughtworks.go.CurrentGoCDVersion.apiDocsUrl
+import static com.thoughtworks.go.api.base.JsonOutputWriter.jsonDate
+import static com.thoughtworks.go.api.base.JsonUtils.toObjectString
+import static com.thoughtworks.go.helper.AccessTokenMother.accessTokenWithName
+import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson
+
+class AccessTokenRepresenterTest {
+  @Test
+  void 'renders the access token hal representation with token value'() {
+    def token = accessTokenWithName("token1")
+
+    def json = toObjectString({
+      AccessTokenRepresenter.toJSON(it, token, true)
+    })
+
+    def expectedJSON = [
+      "_links"        : [
+        "self": [
+          "href": "http://test.host/go/api/access_token/token1"
+        ],
+        "doc" : [
+          "href": apiDocsUrl('#access_token')
+        ],
+        "find": [
+          "href": "http://test.host/go/api/access_token/:token_name"
+        ]
+      ],
+      "name"          : token.getName(),
+      "description"   : token.getDescription(),
+      "auth_config_id": token.authConfigId,
+      "_meta"         : [
+        "is_revoked"  : token.isRevoked(),
+        "revoked_at"  : null,
+        "created_at"  : jsonDate(token.getCreatedAt()),
+        "last_used_at": null
+      ],
+      "token"         : token.getOriginalValue()
+    ]
+
+    assertThatJson(json).isEqualTo(expectedJSON)
+  }
+
+  @Test
+  void 'renders the access token metadata hal representation without token value'() {
+    def token = accessTokenWithName("token1")
+
+    def json = toObjectString({
+      AccessTokenRepresenter.toJSON(it, token, false)
+    })
+
+    def expectedJSON = [
+      "_links"        : [
+        "self": [
+          "href": "http://test.host/go/api/access_token/token1"
+        ],
+        "doc" : [
+          "href": apiDocsUrl('#access_token')
+        ],
+        "find": [
+          "href": "http://test.host/go/api/access_token/:token_name"
+        ]
+      ],
+      "name"          : token.getName(),
+      "description"   : token.getDescription(),
+      "auth_config_id": token.authConfigId,
+      "_meta"         : [
+        "is_revoked"  : token.isRevoked(),
+        "revoked_at"  : null,
+        "created_at"  : jsonDate(token.getCreatedAt()),
+        "last_used_at": null
+      ]
+    ]
+
+    assertThatJson(json).isEqualTo(expectedJSON)
+  }
+}

programming_language: Scala
filename: AccessTokensRepresenterTest.groovy
change_type: ModificationType.ADD
diff: @@ -0,0 +1,99 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.apiv1.accessToken.representers
+
+
+import org.junit.jupiter.api.Test
+
+import static com.thoughtworks.go.CurrentGoCDVersion.apiDocsUrl
+import static com.thoughtworks.go.api.base.JsonOutputWriter.jsonDate
+import static com.thoughtworks.go.api.base.JsonUtils.toObjectString
+import static com.thoughtworks.go.helper.AccessTokenMother.accessTokenWithName
+import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson
+
+class AccessTokensRepresenterTest {
+  @Test
+  void 'renders the access tokens hal representation without token value'() {
+    def token1 = accessTokenWithName("token1")
+    def token2 = accessTokenWithName("token2")
+
+    def json = toObjectString({
+      AccessTokensRepresenter.toJSON(it, [token1, token2])
+    })
+
+    def expectedJSON = [
+      "_links"   : [
+        "self": [
+          "href": "http://test.host/go/api/access_token"
+        ],
+        "doc" : [
+          "href": apiDocsUrl('#access_token')
+        ],
+      ],
+      "_embedded": [
+        "access_tokens": [
+          [
+            "_links"        : [
+              "self": [
+                "href": "http://test.host/go/api/access_token/token1"
+              ],
+              "doc" : [
+                "href": apiDocsUrl('#access_token')
+              ],
+              "find": [
+                "href": "http://test.host/go/api/access_token/:token_name"
+              ]
+            ],
+            "name"          : token1.getName(),
+            "description"   : token1.getDescription(),
+            "auth_config_id": token1.authConfigId,
+            "_meta"         : [
+              "is_revoked"  : token1.isRevoked(),
+              "revoked_at"  : null,
+              "created_at"  : jsonDate(token1.getCreatedAt()),
+              "last_used_at": null
+            ]
+          ],
+          [
+            "_links"        : [
+              "self": [
+                "href": "http://test.host/go/api/access_token/token2"
+              ],
+              "doc" : [
+                "href": apiDocsUrl('#access_token')
+              ],
+              "find": [
+                "href": "http://test.host/go/api/access_token/:token_name"
+              ]
+            ],
+            "name"          : token2.getName(),
+            "description"   : token2.getDescription(),
+            "auth_config_id": token2.authConfigId,
+            "_meta"         : [
+              "is_revoked"  : token2.isRevoked(),
+              "revoked_at"  : null,
+              "created_at"  : jsonDate(token2.getCreatedAt()),
+              "last_used_at": null
+            ]
+          ]
+        ]
+      ]
+    ]
+
+    assertThatJson(json).isEqualTo(expectedJSON)
+  }
+}

programming_language: Scala
filename: AccessToken.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,200 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.domain;
+
+import java.sql.Timestamp;
+import java.util.Date;
+import java.util.Objects;
+
+public class AccessToken extends PersistentObject {
+    private String name;
+    //this is the hashed token value
+    private String value;
+    private String originalValue;
+    private String description;
+    private Boolean isRevoked = false;
+    private Timestamp revokedAt;
+    private Timestamp createdAt;
+    private Timestamp lastUsed;
+    private String username;
+    private String saltId;
+    private String saltValue;
+    private String authConfigId;
+
+    public AccessToken() {
+    }
+
+    public AccessToken(String name, String value) {
+        this.name = name;
+        this.value = value;
+    }
+
+    public AccessToken(String name, String value, String description) {
+        this(name, value);
+        this.description = description;
+    }
+
+    public AccessToken(String name, String value, String description, Boolean isRevoked) {
+        this(name, value, description);
+        this.isRevoked = isRevoked;
+    }
+
+    public AccessToken(String name, String value, String description, Boolean isRevoked, Date createdAt, Date lastUsed) {
+        this(name, value, description, isRevoked);
+        this.createdAt = new Timestamp(createdAt.getTime());
+        setLastUsed(lastUsed);
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public void setValue(String value) {
+        this.value = value;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    public Boolean isRevoked() {
+        return isRevoked;
+    }
+
+    public void setRevoked(Boolean revoked) {
+        isRevoked = revoked;
+    }
+
+    public Date getLastUsed() {
+        return lastUsed;
+    }
+
+    public void setLastUsed(Date lastUsed) {
+        this.lastUsed = lastUsed != null ? new Timestamp(lastUsed.getTime()) : null;
+    }
+
+    public Date getCreatedAt() {
+        return createdAt;
+    }
+
+    public void setCreatedAt(Date createdAt) {
+        this.createdAt = new Timestamp(createdAt.getTime());
+    }
+
+    public String getOriginalValue() {
+        return originalValue;
+    }
+
+    public void setOriginalValue(String originalValue) {
+        this.originalValue = originalValue;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public Timestamp getRevokedAt() {
+        return revokedAt;
+    }
+
+    public void setRevokedAt(Timestamp revokedAt) {
+        this.revokedAt = revokedAt;
+    }
+
+    public String getSaltId() {
+        return saltId;
+    }
+
+    public void setSaltId(String saltId) {
+        this.saltId = saltId;
+    }
+
+    public String getSaltValue() {
+        return saltValue;
+    }
+
+    public void setSaltValue(String saltValue) {
+        this.saltValue = saltValue;
+    }
+
+    public String getAuthConfigId() {
+        return authConfigId;
+    }
+
+    public void setAuthConfigId(String authConfigId) {
+        this.authConfigId = authConfigId;
+    }
+
+    @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;
+        AccessToken accessToken = (AccessToken) o;
+        return Objects.equals(name, accessToken.name) &&
+                Objects.equals(value, accessToken.value) &&
+                Objects.equals(originalValue, accessToken.originalValue) &&
+                Objects.equals(description, accessToken.description) &&
+                Objects.equals(isRevoked, accessToken.isRevoked) &&
+                Objects.equals(revokedAt, accessToken.revokedAt) &&
+                Objects.equals(createdAt, accessToken.createdAt) &&
+                Objects.equals(lastUsed, accessToken.lastUsed) &&
+                Objects.equals(username, accessToken.username) &&
+                Objects.equals(saltId, accessToken.saltId) &&
+                Objects.equals(saltValue, accessToken.saltValue) &&
+                Objects.equals(authConfigId, accessToken.authConfigId);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(super.hashCode(), name, value, originalValue, description, isRevoked, revokedAt, createdAt, lastUsed, username, saltId, saltValue, authConfigId);
+    }
+
+    @Override
+    public String toString() {
+        return "AccessToken{" +
+                "name='" + name + '\'' +
+                ", value='" + value + '\'' +
+                ", originalValue='" + originalValue + '\'' +
+                ", description='" + description + '\'' +
+                ", isRevoked=" + isRevoked +
+                ", revokedAt=" + revokedAt +
+                ", createdAt=" + createdAt +
+                ", lastUsed=" + lastUsed +
+                ", username='" + username + '\'' +
+                ", saltId='" + saltId + '\'' +
+                ", saltValue='" + saltValue + '\'' +
+                ", authConfigId='" + authConfigId + '\'' +
+                '}';
+    }
+}

programming_language: Java
filename: AccessTokenMother.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,47 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.helper;
+
+import com.thoughtworks.go.domain.AccessToken;
+import org.apache.commons.lang3.RandomStringUtils;
+
+import java.util.Date;
+
+public class AccessTokenMother {
+    public static AccessToken accessTokenWithName(String tokenName) {
+        String tokenValue = RandomStringUtils.randomAlphanumeric(32).toUpperCase();
+        String tokenDescription = RandomStringUtils.randomAlphanumeric(512).toUpperCase();
+        String saltId = RandomStringUtils.randomAlphanumeric(8).toUpperCase();
+        String saltValue = RandomStringUtils.randomAlphanumeric(255).toUpperCase();
+        String authConfigId = RandomStringUtils.randomAlphanumeric(255).toUpperCase();
+
+        AccessToken accessToken = new AccessToken(tokenName, tokenValue, tokenDescription, false, new Date(), null);
+        accessToken.setSaltId(saltId);
+        accessToken.setSaltValue(saltValue);
+        accessToken.setAuthConfigId(authConfigId);
+        accessToken.setUsername("Bob");
+
+        return accessToken;
+    }
+
+    public static AccessToken accessTokenWithNameForUser(String tokenName, String username) {
+        AccessToken accessToken = accessTokenWithName(tokenName);
+        accessToken.setUsername(username);
+
+        return accessToken;
+    }
+}

programming_language: Java
filename: cruise-config.xml
change_type: ModificationType.MODIFY
diff: @@ -14,6 +14,7 @@
         <role name="xyz" />
       </roles>
       <admins>
+        <user>jez</user>
         <user>admin</user>
       </admins>
     </security>

programming_language: HTML
filename: 1902002_add_access_token.sql
change_type: ModificationType.ADD
diff: @@ -0,0 +1,33 @@
+--*************************GO-LICENSE-START*********************************
+-- Copyright 2014 ThoughtWorks, 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.
+--*************************GO-LICENSE-END***********************************
+
+CREATE TABLE AccessToken (
+id             BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+name           VARCHAR_IGNORECASE(255) NOT NULL,
+value          VARCHAR(255) UNIQUE NOT NULL,
+saltId         VARCHAR(8) UNIQUE NOT NULL,
+saltValue      VARCHAR(255) UNIQUE NOT NULL,
+description    VARCHAR(1024),
+isRevoked      BOOLEAN,
+revokedAt      TIMESTAMP,
+createdAt      TIMESTAMP,
+lastUsed       TIMESTAMP,
+username       VARCHAR_IGNORECASE(255) NOT NULL,
+authConfigId   VARCHAR_IGNORECASE(255) NOT NULL
+);
+
+--//@UNDO
+DROP TABLE IF EXISTS AccessToken;

programming_language: SQL
filename: AccessTokenDao.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,35 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.dao;
+
+import com.thoughtworks.go.domain.AccessToken;
+
+import java.util.List;
+
+public interface AccessTokenDao {
+    void saveOrUpdate(AccessToken accessToken);
+
+    AccessToken findAccessToken(String name, String username);
+
+    AccessToken load(long id);
+
+    void deleteAll();
+
+    List<AccessToken> findAllTokensForUser(String toString);
+
+    AccessToken findAccessTokenBySaltId(String saltId);
+}

programming_language: Java
filename: AccessTokenSqlMapDao.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,96 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.dao;
+
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.server.transaction.TransactionTemplate;
+import org.hibernate.Query;
+import org.hibernate.SessionFactory;
+import org.hibernate.criterion.Restrictions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.orm.hibernate3.support.HibernateDaoSupport;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.TransactionStatus;
+import org.springframework.transaction.support.TransactionCallback;
+import org.springframework.transaction.support.TransactionCallbackWithoutResult;
+
+import java.util.List;
+
+@Component
+public class AccessTokenSqlMapDao extends HibernateDaoSupport implements AccessTokenDao {
+    private SessionFactory sessionFactory;
+    private TransactionTemplate transactionTemplate;
+
+    @Autowired
+    public AccessTokenSqlMapDao(SessionFactory sessionFactory,
+                                TransactionTemplate transactionTemplate) {
+        this.sessionFactory = sessionFactory;
+        this.transactionTemplate = transactionTemplate;
+        setSessionFactory(sessionFactory);
+    }
+
+    public void saveOrUpdate(final AccessToken accessToken) {
+        transactionTemplate.execute(new TransactionCallbackWithoutResult() {
+            @Override
+            protected void doInTransactionWithoutResult(TransactionStatus status) {
+                sessionFactory.getCurrentSession().saveOrUpdate(accessToken);
+            }
+        });
+    }
+
+    public AccessToken findAccessToken(final String tokenName, String username) {
+        return (AccessToken) transactionTemplate.execute((TransactionCallback) transactionStatus ->
+                sessionFactory.getCurrentSession()
+                        .createCriteria(AccessToken.class)
+                        .add(Restrictions.eq("name", tokenName))
+                        .add(Restrictions.eq("username", username))
+                        .setCacheable(true).uniqueResult());
+    }
+
+    @Override
+    public List<AccessToken> findAllTokensForUser(String username) {
+        return (List<AccessToken>) transactionTemplate.execute((TransactionCallback) transactionStatus -> {
+            Query query = sessionFactory.getCurrentSession().createQuery("FROM AccessToken where username = :username");
+            query.setString("username", username);
+            query.setCacheable(true);
+            return query.list();
+        });
+    }
+
+    @Override
+    public AccessToken findAccessTokenBySaltId(String saltId) {
+        return (AccessToken) transactionTemplate.execute((TransactionCallback) transactionStatus ->
+                sessionFactory.getCurrentSession()
+                        .createCriteria(AccessToken.class)
+                        .add(Restrictions.eq("saltId", saltId))
+                        .setCacheable(true).uniqueResult());
+    }
+
+    public AccessToken load(final long id) {
+        return (AccessToken) transactionTemplate.execute((TransactionCallback) transactionStatus -> sessionFactory.getCurrentSession().get(AccessToken.class, id));
+    }
+
+    // Used only by tests
+    public void deleteAll() {
+        transactionTemplate.execute(new TransactionCallbackWithoutResult() {
+            @Override
+            protected void doInTransactionWithoutResult(TransactionStatus status) {
+                sessionFactory.getCurrentSession().createQuery("DELETE FROM AccessToken").executeUpdate();
+            }
+        });
+    }
+}

programming_language: Java
filename: InvalidAccessTokenException.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,23 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.exceptions;
+
+public class InvalidAccessTokenException extends RuntimeException {
+    public InvalidAccessTokenException() {
+        super("Invalid Personal Access Token.");
+    }
+}

programming_language: Java
filename: RevokedAccessTokenException.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,25 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.exceptions;
+
+import java.sql.Timestamp;
+
+public class RevokedAccessTokenException extends RuntimeException {
+    public RevokedAccessTokenException(Timestamp revokedAt) {
+        super(String.format("Invalid Personal Access Token. Access token was revoked at: %s", revokedAt));
+    }
+}

programming_language: Java
filename: AccessTokenService.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,172 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.service;
+
+import com.thoughtworks.go.config.validation.NameTypeValidator;
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.server.dao.AccessTokenDao;
+import com.thoughtworks.go.server.domain.Username;
+import com.thoughtworks.go.server.exceptions.InvalidAccessTokenException;
+import com.thoughtworks.go.server.exceptions.RevokedAccessTokenException;
+import com.thoughtworks.go.server.service.result.HttpLocalizedOperationResult;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import java.security.SecureRandom;
+import java.sql.Timestamp;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class AccessTokenService {
+    private static final int DEFAULT_ITERATIONS = 4096;
+    private static final int DESIRED_KEY_LENGTH = 256;
+
+    private static final int SALT_LENGTH = 32;
+
+    private AccessTokenDao accessTokenDao;
+
+    @Autowired
+    public AccessTokenService(AccessTokenDao accessTokenDao) {
+        this.accessTokenDao = accessTokenDao;
+    }
+
+    public AccessToken create(String tokenName, String description, Username username, String authConfigId, HttpLocalizedOperationResult result) throws Exception {
+        if (!new NameTypeValidator().isNameValid(tokenName)) {
+            result.unprocessableEntity(NameTypeValidator.errorMessage("access token", tokenName));
+            return null;
+        }
+
+        if (description != null && description.length() > 1024) {
+            result.unprocessableEntity("Validation Failed. Access token description can not be longer than 1024 characters.");
+            return null;
+        }
+
+        if (hasTokenWithNameForTheUser(tokenName, username)) {
+            result.conflict(String.format("Validation Failed. Another access token with name '%s' already exists.", tokenName));
+            return null;
+        }
+
+        AccessToken tokenToCreate = generateAccessTokenFor(tokenName, description, username, authConfigId);
+        accessTokenDao.saveOrUpdate(tokenToCreate);
+        return tokenToCreate;
+    }
+
+    private boolean hasTokenWithNameForTheUser(String tokenName, Username username) {
+        return accessTokenDao.findAccessToken(tokenName, username.getUsername().toString()) != null;
+    }
+
+    private AccessToken generateAccessTokenFor(String tokenName, String description, Username username, String authConfigId) throws Exception {
+        AccessToken accessToken = new AccessToken();
+
+        accessToken.setName(tokenName);
+        accessToken.setDescription(description);
+        accessToken.setAuthConfigId(authConfigId);
+        accessToken.setCreatedAt(new Date());
+
+        String originalToken = generateSecureRandomString(16);
+        String saltId = generateSecureRandomString(4);
+        String saltValue = generateSalt();
+        String hashedToken = digestToken(originalToken, saltValue);
+        String finalTokenValue = String.format("%s%s", saltId, originalToken);
+
+        accessToken.setOriginalValue(finalTokenValue);
+        accessToken.setSaltId(saltId);
+        accessToken.setSaltValue(saltValue);
+        accessToken.setValue(hashedToken);
+
+        accessToken.setUsername(username.getUsername().toString());
+
+        return accessToken;
+    }
+
+    String digestToken(String originalToken, String salt) throws Exception {
+        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+        SecretKey key = factory.generateSecret(new PBEKeySpec(originalToken.toCharArray(), salt.getBytes(), DEFAULT_ITERATIONS, DESIRED_KEY_LENGTH));
+        return Hex.encodeHexString(key.getEncoded());
+    }
+
+    private String generateSecureRandomString(int byteLength) {
+        byte[] randomBytes = new byte[byteLength];
+        new SecureRandom().nextBytes(randomBytes);
+        return Hex.encodeHexString(randomBytes);
+    }
+
+    private static String generateSalt() throws Exception {
+        return Base64.encodeBase64String(SecureRandom.getInstance("SHA1PRNG").generateSeed(SALT_LENGTH));
+    }
+
+    public AccessToken find(String tokenName, String username) {
+        return accessTokenDao.findAccessToken(tokenName, username);
+    }
+
+    public AccessToken findByAccessToken(String actualToken) throws Exception {
+        if (actualToken.length() != 40) {
+            throw new InvalidAccessTokenException();
+        }
+
+        String saltId = StringUtils.substring(actualToken, 0, 8);
+        String originalToken = StringUtils.substring(actualToken, 8);
+
+        AccessToken token = accessTokenDao.findAccessTokenBySaltId(saltId);
+        if (token == null) {
+            throw new InvalidAccessTokenException();
+        }
+
+        String saltValue = token.getSaltValue();
+        String digestOfUserProvidedToken = digestToken(originalToken, saltValue);
+
+        if (!token.getValue().equals(digestOfUserProvidedToken)) {
+            throw new InvalidAccessTokenException();
+        }
+
+        if (token.isRevoked()) {
+            throw new RevokedAccessTokenException(token.getRevokedAt());
+        }
+
+        return token;
+    }
+
+    public void revokeAccessToken(String name, String username, HttpLocalizedOperationResult result) {
+        AccessToken fetchedAccessToken = accessTokenDao.findAccessToken(name, username);
+
+        if (fetchedAccessToken == null) {
+            result.unprocessableEntity(String.format("Validation Failed. Access Token with name '%s' for user '%s' does not exists.", name, username));
+            return;
+        }
+
+        if (fetchedAccessToken.isRevoked()) {
+            result.unprocessableEntity(String.format("Validation Failed. Access Token with name '%s' for user '%s' has already been revoked.", name, username));
+            return;
+        }
+
+        fetchedAccessToken.setRevoked(true);
+        fetchedAccessToken.setRevokedAt(new Timestamp(System.currentTimeMillis()));
+
+        accessTokenDao.saveOrUpdate(fetchedAccessToken);
+    }
+
+    public List<AccessToken> findAllTokensForUser(Username username) {
+        return accessTokenDao.findAllTokensForUser(username.getUsername().toString());
+    }
+}

programming_language: Java
filename: AccessToken.hbm.xml
change_type: ModificationType.ADD
diff: @@ -0,0 +1,40 @@
+<?xml version="1.0"?>
+<!--
+  ~ Copyright 2019 ThoughtWorks, 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.
+  -->
+
+<!DOCTYPE hibernate-mapping PUBLIC
+    "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
+    "http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">
+
+<hibernate-mapping package="com.thoughtworks.go.domain" default-access="field" default-lazy="false">
+    <class name="AccessToken" table="AccessToken">
+        <cache usage="read-write"/>
+        <id name="id" column="id">
+            <generator class="identity"/>
+        </id>
+        <property name="name"/>
+        <property name="value"/>
+        <property name="description"/>
+        <property name="isRevoked"/>
+        <property name="revokedAt"/>
+        <property name="createdAt"/>
+        <property name="lastUsed"/>
+        <property name="username"/>
+        <property name="saltId"/>
+        <property name="saltValue"/>
+        <property name="authConfigId"/>
+    </class>
+</hibernate-mapping>

programming_language: HTML
filename: AccessTokenServiceTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,147 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.service;
+
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.server.dao.AccessTokenDao;
+import com.thoughtworks.go.server.domain.Username;
+import com.thoughtworks.go.server.service.result.HttpLocalizedOperationResult;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.lang.RandomStringUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+class AccessTokenServiceTest {
+    @Mock
+    private AccessTokenDao accessTokenDao;
+    private AccessTokenService accessTokenService;
+    private HttpLocalizedOperationResult result;
+    private Username username;
+    private String authConfigId;
+
+    @BeforeEach
+    void setUp() {
+        initMocks(this);
+        accessTokenService = new AccessTokenService(accessTokenDao);
+        result = new HttpLocalizedOperationResult();
+
+        username = new Username("Bob");
+        authConfigId = "auth-config-1";
+    }
+
+    @Test
+    void shouldValidateAccessTokenName() throws Exception {
+        String invalidTokenName = "@#my_%_fancy_%_token#@";
+        accessTokenService.create(invalidTokenName, null, username, authConfigId, result);
+
+        assertThat(result.isSuccessful()).isFalse();
+        assertThat(result.httpCode()).isEqualTo(422);
+        assertThat(result.message()).isEqualTo(String.format("Invalid access token name '%s'. This must be alphanumeric and can contain underscores and periods (however, it cannot start with a period). The maximum allowed length is 255 characters.", invalidTokenName));
+
+        verifyNoMoreInteractions(accessTokenDao);
+    }
+
+    @Test
+    void shouldValidateAccessTokenDescription() throws Exception {
+        String tokenName = "token1";
+        String longerDescription = RandomStringUtils.randomAlphanumeric(1025).toUpperCase();
+        accessTokenService.create(tokenName, longerDescription, username, authConfigId, result);
+
+        assertThat(result.isSuccessful()).isFalse();
+        assertThat(result.httpCode()).isEqualTo(422);
+        assertThat(result.message()).isEqualTo("Validation Failed. Access token description can not be longer than 1024 characters.");
+
+        verifyNoMoreInteractions(accessTokenDao);
+    }
+
+    @Test
+    void shouldMakeACallToSQLDaoForAccessTokenCreation() throws Exception {
+        String tokenName = "token1";
+        String longerDescription = RandomStringUtils.randomAlphanumeric(1024).toUpperCase();
+        accessTokenService.create(tokenName, longerDescription, username, authConfigId, result);
+
+        assertThat(result.isSuccessful()).isTrue();
+
+        verify(accessTokenDao, times(1)).saveOrUpdate(any(AccessToken.class));
+    }
+
+    @Test
+    void shouldMakeACallToSQLDaoForFetchingAccessToken() {
+        String tokenName = "token1";
+        accessTokenService.find(tokenName, username.getUsername().toString());
+
+        verify(accessTokenDao, times(1)).findAccessToken(tokenName, username.getUsername().toString());
+        verifyNoMoreInteractions(accessTokenDao);
+    }
+
+    @Test
+    void shouldMakeACallToSQLDaoForFetchingAllAccessTokensBelongingToAUser() {
+        accessTokenService.findAllTokensForUser(username);
+
+        verify(accessTokenDao, times(1)).findAllTokensForUser(username.getUsername().toString());
+        verifyNoMoreInteractions(accessTokenDao);
+    }
+
+    @Test
+    void shouldValidateExistenceOfAnotherAccessTokenWithTheSameName() throws Exception {
+        String tokenName = "token1";
+        String longerDescription = RandomStringUtils.randomAlphanumeric(1024).toUpperCase();
+
+        when(accessTokenDao.findAccessToken(tokenName, username.getUsername().toString())).thenReturn(new AccessToken(tokenName, "value"));
+
+        accessTokenService.create(tokenName, longerDescription, username, authConfigId, result);
+
+        assertThat(result.isSuccessful()).isFalse();
+        assertThat(result.httpCode()).isEqualTo(409);
+        assertThat(result.message()).isEqualTo("Validation Failed. Another access token with name 'token1' already exists.");
+
+        verify(accessTokenDao, times(1)).findAccessToken(tokenName, username.getUsername().toString());
+        verifyNoMoreInteractions(accessTokenDao);
+    }
+
+    @Test
+    void hashToken_shouldHashTheProvidedString() throws Exception {
+        String tokenValue = "token1";
+        String saltValue = "salt1";
+        String hashed = accessTokenService.digestToken(tokenValue, saltValue);
+
+        SecretKey key = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
+                .generateSecret(new PBEKeySpec(tokenValue.toCharArray(), saltValue.getBytes(), 4096, 256));
+
+        assertThat(hashed).isEqualTo(Hex.encodeHexString(key.getEncoded()));
+    }
+
+    @Test
+    void hashToken_shouldGenerateTheSameHashValueForTheSameInputString() throws Exception {
+        String tokenValue = "new-token";
+        String saltValue = "new-salt";
+        String hashed1 = accessTokenService.digestToken(tokenValue, saltValue);
+        String hashed2 = accessTokenService.digestToken(tokenValue, saltValue);
+
+        assertThat(hashed1).isEqualTo(hashed2);
+    }
+}

programming_language: Java
filename: AccessTokenSqlMapDaoIntegrationTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,122 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.dao;
+
+import com.thoughtworks.go.domain.AccessToken;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+
+import java.util.List;
+
+import static com.thoughtworks.go.helper.AccessTokenMother.accessTokenWithName;
+import static com.thoughtworks.go.helper.AccessTokenMother.accessTokenWithNameForUser;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@RunWith(SpringJUnit4ClassRunner.class)
+@ContextConfiguration(locations = {
+        "classpath:WEB-INF/applicationContext-global.xml",
+        "classpath:WEB-INF/applicationContext-dataLocalAccess.xml",
+        "classpath:testPropertyConfigurer.xml"
+})
+public class AccessTokenSqlMapDaoIntegrationTest {
+    @Autowired
+    private AccessTokenSqlMapDao accessTokenSqlMapDao;
+
+    @Autowired
+    private DatabaseAccessHelper dbHelper;
+
+    private String username;
+
+    @Before
+    public void setup() throws Exception {
+        dbHelper.onSetUp();
+        username = "Bob";
+    }
+
+    @After
+    public void teardown() throws Exception {
+        accessTokenSqlMapDao.deleteAll();
+        dbHelper.onTearDown();
+    }
+
+    @Test
+    public void shouldSaveUsersIntoDatabase() {
+        String tokenName = "access-token-for-apis";
+        AccessToken accessToken = accessTokenWithName(tokenName);
+
+        accessTokenSqlMapDao.saveOrUpdate(accessToken);
+
+        AccessToken savedAccessToken = accessTokenSqlMapDao.findAccessToken(tokenName, username);
+        assertThat(savedAccessToken).isEqualTo(accessToken);
+        assertThat(accessTokenSqlMapDao.load(savedAccessToken.getId())).isEqualTo(accessToken);
+    }
+
+    @Test
+    public void shouldReturnNullWhenNoAccessTokenFoundForTheSpecifiedName() {
+        String tokenName = "access-token-for-apis";
+        AccessToken savedAccessToken = accessTokenSqlMapDao.findAccessToken(tokenName, username);
+        assertThat(savedAccessToken).isNull();
+    }
+
+    @Test
+    public void shouldReturnAllTheAccessTokensBelongingToAUser() {
+        String user1 = "Bob";
+        String user2 = "John";
+
+        String tokenName1 = "token1-created-by-Bob";
+        String tokenName2 = "token2-created-by-Bob";
+        String tokenName3 = "token2-created-by-John";
+
+        accessTokenSqlMapDao.saveOrUpdate(accessTokenWithNameForUser(tokenName1, user1));
+        accessTokenSqlMapDao.saveOrUpdate(accessTokenWithNameForUser(tokenName2, user1));
+        accessTokenSqlMapDao.saveOrUpdate(accessTokenWithNameForUser(tokenName3, user2));
+
+        List<AccessToken> user1AccessTokens = accessTokenSqlMapDao.findAllTokensForUser(user1);
+        List<AccessToken> user2AccessTokens = accessTokenSqlMapDao.findAllTokensForUser(user2);
+
+        assertThat(user1AccessTokens).hasSize(2);
+        assertThat(user2AccessTokens).hasSize(1);
+
+        assertThat(user1AccessTokens.get(0).getName()).isEqualTo(tokenName1);
+        assertThat(user1AccessTokens.get(1).getName()).isEqualTo(tokenName2);
+
+        assertThat(user2AccessTokens.get(0).getName()).isEqualTo(tokenName3);
+    }
+
+    @Test
+    public void shouldLoadAccessTokenBasedOnSaltId() {
+        String tokenName = "access-token-for-apis";
+        AccessToken accessToken = accessTokenWithName(tokenName);
+
+        accessTokenSqlMapDao.saveOrUpdate(accessToken);
+
+        AccessToken savedAccessToken = accessTokenSqlMapDao.findAccessTokenBySaltId(accessToken.getSaltId());
+        assertThat(savedAccessToken).isEqualTo(accessToken);
+    }
+
+    @Test
+    public void shouldReturnNullWhenNoAccessTokenFoundForTheSpecifiedSaltId() {
+        String saltId = "access-token-for-apis";
+        AccessToken savedAccessToken = accessTokenSqlMapDao.findAccessTokenBySaltId(saltId);
+        assertThat(savedAccessToken).isNull();
+    }
+}

programming_language: Java
filename: AccessTokenServiceIntegrationTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,238 @@
+/*
+ * Copyright 2019 ThoughtWorks, 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.thoughtworks.go.server.service;
+
+import com.thoughtworks.go.domain.AccessToken;
+import com.thoughtworks.go.server.dao.AccessTokenSqlMapDao;
+import com.thoughtworks.go.server.dao.DatabaseAccessHelper;
+import com.thoughtworks.go.server.domain.Username;
+import com.thoughtworks.go.server.exceptions.InvalidAccessTokenException;
+import com.thoughtworks.go.server.exceptions.RevokedAccessTokenException;
+import com.thoughtworks.go.server.service.result.HttpLocalizedOperationResult;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+
+import static com.thoughtworks.go.server.newsecurity.utils.SessionUtils.currentUsername;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+
+@RunWith(SpringJUnit4ClassRunner.class)
+@ContextConfiguration(locations = {
+        "classpath:WEB-INF/applicationContext-global.xml",
+        "classpath:WEB-INF/applicationContext-dataLocalAccess.xml",
+        "classpath:testPropertyConfigurer.xml"
+})
+public class AccessTokenServiceIntegrationTest {
+    @Autowired
+    DatabaseAccessHelper dbHelper;
+
+    @Autowired
+    AccessTokenService accessTokenService;
+
+    @Autowired
+    AccessTokenSqlMapDao accessTokenSqlMapDao;
+    private Username username;
+    private String authConfigId;
+
+    @Before
+    public void setUp() throws Exception {
+        dbHelper.onSetUp();
+        username = currentUsername();
+        authConfigId = "auth-config-1";
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        accessTokenSqlMapDao.deleteAll();
+        dbHelper.onTearDown();
+    }
+
+    @Test
+    public void shouldCreateAnAccessToken() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        AccessToken createdToken = accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, result);
+
+        AccessToken fetchedToken = accessTokenService.find(tokenName, username.getUsername().toString());
+
+        assertThat(result.isSuccessful()).isTrue();
+        assertThat(createdToken.getName()).isEqualTo(tokenName);
+        assertThat(createdToken.getDescription()).isEqualTo(tokenDescription);
+        assertThat(createdToken.getValue()).isNotNull();
+        assertThat(createdToken.getOriginalValue()).isNotNull();
+        assertThat(createdToken.getCreatedAt()).isNotNull();
+        assertThat(createdToken.getLastUsed()).isNull();
+        assertThat(createdToken.isRevoked()).isFalse();
+
+        assertThat(fetchedToken.getValue()).isEqualTo(createdToken.getValue());
+        assertThat(fetchedToken.getName()).isEqualTo(createdToken.getName());
+        assertThat(fetchedToken.getDescription()).isEqualTo(createdToken.getDescription());
+        assertThat(fetchedToken.getCreatedAt()).isEqualTo(createdToken.getCreatedAt());
+        assertThat(fetchedToken.getLastUsed()).isEqualTo(createdToken.getLastUsed());
+        assertThat(fetchedToken.isRevoked()).isEqualTo(createdToken.isRevoked());
+
+        assertThat(fetchedToken.getOriginalValue()).isNull();
+    }
+
+    @Test
+    public void shouldFailToCreateAccessTokenWhenOneWithTheSameNameAlreadyExists() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, result);
+        assertThat(result.isSuccessful()).isTrue();
+
+        AccessToken savedToken = accessTokenService.find(tokenName, username.getUsername().toString());
+        assertThat(savedToken.getName()).isEqualTo(tokenName);
+
+        accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, result);
+        assertThat(result.isSuccessful()).isFalse();
+        assertThat(result.httpCode()).isEqualTo(409);
+        assertThat(result.message()).isEqualTo("Validation Failed. Another access token with name 'token1' already exists.");
+    }
+
+    @Test
+    public void shouldAllowDifferentUsersToCreateAccessTokenWhenWithSameName() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, result);
+        assertThat(result.isSuccessful()).isTrue();
+
+        AccessToken savedToken = accessTokenService.find(tokenName, username.getUsername().toString());
+        assertThat(savedToken.getName()).isEqualTo(tokenName);
+
+        accessTokenService.create(tokenName, tokenDescription, new Username("Another User"), authConfigId, result);
+
+        assertThat(result.isSuccessful()).isTrue();
+    }
+
+    @Test
+    public void shouldGetAccessTokenProvidedTokenValue() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first Token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        AccessToken createdToken = accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, result);
+        String accessTokenInString = createdToken.getOriginalValue();
+        createdToken.setOriginalValue(null);
+        AccessToken fetchedToken = accessTokenService.findByAccessToken(accessTokenInString);
+
+        assertThat(createdToken).isEqualTo(fetchedToken);
+    }
+
+    @Test
+    public void shouldFailToGetAccessTokenWhenProvidedTokenLengthIsNotEqualTo40() {
+        InvalidAccessTokenException exception = assertThrows(InvalidAccessTokenException.class, () -> accessTokenService.findByAccessToken("my-access-token"));
+        assertThat("Invalid Personal Access Token.").isEqualTo(exception.getMessage());
+    }
+
+    @Test
+    public void shouldFailToGetAccessTokenWhenProvidedTokenContainsInvalidSaltId() {
+        String accessToken = RandomStringUtils.randomAlphanumeric(40);
+        InvalidAccessTokenException exception = assertThrows(InvalidAccessTokenException.class, () -> accessTokenService.findByAccessToken(accessToken));
+        assertThat("Invalid Personal Access Token.").isEqualTo(exception.getMessage());
+    }
+
+    @Test
+    public void shouldFailToGetAccessTokenWhenProvidedTokenHashEqualityFails() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first Token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        AccessToken createdToken = accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, result);
+        String accessTokenInString = createdToken.getOriginalValue();
+        //replace last 5 characters to make the current token invalid
+        String invalidAccessToken = StringUtils.replace(accessTokenInString, accessTokenInString.substring(35), "abcde");
+
+        InvalidAccessTokenException exception = assertThrows(InvalidAccessTokenException.class, () -> accessTokenService.findByAccessToken(invalidAccessToken));
+        assertThat("Invalid Personal Access Token.").isEqualTo(exception.getMessage());
+    }
+
+    @Test
+    public void shouldNotGetAccessTokenProvidedTokenValueWhenTokenIsRevoked() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first Token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        AccessToken createdToken = accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, result);
+        accessTokenService.revokeAccessToken(createdToken.getName(), currentUsername().getUsername().toString(), result);
+        String accessTokenInString = createdToken.getOriginalValue();
+
+        RevokedAccessTokenException exception = assertThrows(RevokedAccessTokenException.class, () -> accessTokenService.findByAccessToken(accessTokenInString));
+        assertThat(exception.getMessage()).startsWith("Invalid Personal Access Token. Access token was revoked at: ");
+    }
+
+    @Test
+    public void shouldRevokeAnAccessToken() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first Token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+        AccessToken createdToken = accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, new HttpLocalizedOperationResult());
+
+        assertThat(createdToken.isRevoked()).isFalse();
+
+        accessTokenService.revokeAccessToken(tokenName, currentUsername().getUsername().toString(), result);
+        assertThat(result.isSuccessful()).isTrue();
+
+        AccessToken tokenAfterRevoking = accessTokenService.find(tokenName, currentUsername().getUsername().toString());
+        assertThat(tokenAfterRevoking.isRevoked()).isTrue();
+    }
+
+    @Test
+    public void shouldFailToRevokeAnAlreadyRevokedAccessToken() throws Exception {
+        String tokenName = "token1";
+        String tokenDescription = "This is my first Token";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+        AccessToken createdToken = accessTokenService.create(tokenName, tokenDescription, currentUsername(), authConfigId, new HttpLocalizedOperationResult());
+
+        assertThat(createdToken.isRevoked()).isFalse();
+
+        accessTokenService.revokeAccessToken(tokenName, currentUsername().getUsername().toString(), result);
+        assertThat(result.isSuccessful()).isTrue();
+
+        AccessToken tokenAfterRevoking = accessTokenService.find(tokenName, currentUsername().getUsername().toString());
+        assertThat(tokenAfterRevoking.isRevoked()).isTrue();
+
+        accessTokenService.revokeAccessToken(tokenName, currentUsername().getUsername().toString(), result);
+        assertThat(result.isSuccessful()).isFalse();
+
+        assertThat(result.message()).isEqualTo(String.format("Validation Failed. Access Token with name '%s' for user '%s' has already been revoked.", tokenName, currentUsername().getUsername().toString()));
+    }
+
+    @Test
+    public void shouldFailToRevokeNonExistingAccessToken() {
+        String tokenName = "token1";
+        HttpLocalizedOperationResult result = new HttpLocalizedOperationResult();
+
+        accessTokenService.revokeAccessToken(tokenName, currentUsername().getUsername().toString(), result);
+        assertThat(result.isSuccessful()).isFalse();
+        assertThat(result.message()).isEqualTo(String.format("Validation Failed. Access Token with name '%s' for user '%s' does not exists.", tokenName, currentUsername().getUsername().toString()));
+    }
+}

programming_language: Java
filename: DatabaseAccessHelper.java
change_type: ModificationType.MODIFY
diff: @@ -189,6 +189,7 @@ public class DatabaseAccessHelper extends HibernateDaoSupport {
         dataSet.addTable(new DefaultTable("jobAgentMetadata"));
         dataSet.addTable(new DefaultTable("DataSharingSettings"));
         dataSet.addTable(new DefaultTable("UsageDataReporting"));
+        dataSet.addTable(new DefaultTable("AccessToken"));
 
         databaseTester.setDataSet(dataSet);
     }

programming_language: Java
filename: applicationContext-global.xml
change_type: ModificationType.MODIFY
diff: @@ -27,6 +27,7 @@
 
   <context:annotation-config/>
 
+  <context:component-scan base-package="com.thoughtworks.go.apiv1.accessToken"/>
   <context:component-scan base-package="com.thoughtworks.go.apiv2.environments"/>
   <context:component-scan base-package="com.thoughtworks.go.apiv2.adminsconfig"/>
   <context:component-scan base-package="com.thoughtworks.go.apiv2.rolesconfig"/>

programming_language: Scala
filename: urlrewrite.xml
change_type: ModificationType.MODIFY
diff: @@ -46,6 +46,24 @@
     <to last="true">/spark/api/users/${escape:$1}</to>
   </rule>
 
+  <rule>
+    <name>Access Token Index and Create API</name>
+    <from>^/api/access_token(/?)$</from>
+    <to last="true">/spark/api/access_token</to>
+  </rule>
+
+  <rule>
+    <name>Spark Access Token Show API</name>
+    <from>^/api/access_token/(.*)$</from>
+    <to last="true">/spark/api/access_token/${escape:$1}</to>
+  </rule>
+
+  <rule>
+    <name>Spark Access Token Revoke API</name>
+    <from>^/api/access_token/([^/]+)/([^/]+)/revoke</from>
+    <to last="true">/spark/api/access_token/${escape:$1}/${escape:$2}/revoke</to>
+  </rule>
+
   <rule>
     <name>users UI</name>
     <from>^/admin/users(/?)$</from>

programming_language: TypeScript
filename: settings.gradle
change_type: ModificationType.MODIFY
diff: @@ -27,6 +27,7 @@ include ':api:api-admins-config-v2'
 include ':api:api-agents-v4'
 include ':api:api-api-shared-v1'
 include ':api:api-artifact-store-config-v1'
+include ':api:api-access-token-v1'
 include ':api:api-backups-v1'
 include ':api:api-base'
 include ':api:api-build_cause-v1'

programming_language: C++
filename: Routes.java
change_type: ModificationType.MODIFY
diff: @@ -433,4 +433,19 @@ public class Routes {
     public class ArtifactStores {
         public static final String SPA_BASE = "/admin/artifact_stores";
     }
+
+    public static class AccessToken {
+        public static final String BASE = "/api/access_token";
+        public static final String TOKEN_NAME = "/:token_name";
+        public static final String USERNAME = "/:username";
+        public static final String DOC = apiDocsUrl("#access_token");
+
+        public static String find() {
+            return BASE + TOKEN_NAME;
+        }
+
+        public static String name(String tokenName) {
+            return find().replaceAll(":token_name", tokenName);
+        }
+    }
 }

programming_language: Java
filename: SparkController.java
change_type: ModificationType.MODIFY
diff: @@ -18,12 +18,14 @@ package com.thoughtworks.go.spark;
 
 import com.thoughtworks.go.config.CaseInsensitiveString;
 import com.thoughtworks.go.server.domain.Username;
+import com.thoughtworks.go.server.newsecurity.models.AuthenticationToken;
 import com.thoughtworks.go.server.newsecurity.utils.SessionUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.client.utils.URLEncodedUtils;
 import org.apache.http.message.BasicNameValuePair;
 import spark.Request;
 
+import javax.servlet.http.HttpServletRequest;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;

programming_language: Java
filename: ControllerTrait.groovy
change_type: ModificationType.MODIFY
diff: @@ -22,6 +22,8 @@ import com.thoughtworks.go.http.mocks.HttpRequestBuilder
 import com.thoughtworks.go.http.mocks.MockHttpServletRequest
 import com.thoughtworks.go.http.mocks.MockHttpServletResponse
 import com.thoughtworks.go.server.domain.Username
+import com.thoughtworks.go.server.newsecurity.models.AuthenticationToken
+import com.thoughtworks.go.server.newsecurity.models.UsernamePassword
 import com.thoughtworks.go.server.newsecurity.utils.SessionUtils
 import com.thoughtworks.go.spark.mocks.StubTemplateEngine
 import com.thoughtworks.go.spark.mocks.TestApplication
@@ -129,7 +131,10 @@ trait ControllerTrait<T extends SparkController> {
     }
 
     if (!currentUsername().isAnonymous()) {
+      final AuthenticationToken<?> newToken = new AuthenticationToken<>(SessionUtils.getCurrentUser(), new UsernamePassword(currentUserLoginName().toString(), "password"), "plugin1", System.currentTimeMillis(), "authConfigId")
+
       httpRequestBuilder.withSessionAttr(SessionUtils.CURRENT_USER_ID, currentUserLoginId())
+      httpRequestBuilder.withSessionAttr(SessionUtils.AUTHENTICATION_TOKEN, newToken)
     }
 
     request = httpRequestBuilder.build()

programming_language: Scala