CVE-2022-39308: Incorrect Comparison (CWE-697)
value: 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.
CWE description: The product compares two entities in a security-relevant context, but the comparison is incorrect, which may lead to resultant weaknesses.
CVSS v3 base score: 5.9
Published: 2022-10-14T19:15Z
Repository: https://github.com/gocd/gocd
Language: Java
Files changed: .gitignore, api/api-access-token-v1/build.gradle, api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1.java, api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenter.java, api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenter.java, api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1Test.groovy, api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenterTest.groovy, api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenterTest.groovy, common/src/main/java/com/thoughtworks/go/domain/AccessToken.java, common/src/test/java/com/thoughtworks/go/helper/AccessTokenMother.java, server/config/cruise-config.xml, server/db/migrate/h2deltas/1902002_add_access_token.sql, server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenDao.java, server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDao.java, server/src/main/java/com/thoughtworks/go/server/exceptions/InvalidAccessTokenException.java, server/src/main/java/com/thoughtworks/go/server/exceptions/RevokedAccessTokenException.java, server/src/main/java/com/thoughtworks/go/server/service/AccessTokenService.java, server/src/main/resources/hibernate-mappings/AccessToken.hbm.xml, server/src/test-fast/java/com/thoughtworks/go/server/service/AccessTokenServiceTest.java, server/src/test-integration/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDaoIntegrationTest.java, server/src/test-integration/java/com/thoughtworks/go/server/service/AccessTokenServiceIntegrationTest.java, server/src/test-shared/java/com/thoughtworks/go/server/dao/DatabaseAccessHelper.java, server/webapp/WEB-INF/applicationContext-global.xml, server/webapp/WEB-INF/urlrewrite.xml, settings.gradle, spark/spark-base/src/main/java/com/thoughtworks/go/spark/Routes.java, spark/spark-base/src/main/java/com/thoughtworks/go/spark/SparkController.java, spark/spark-base/src/test/groovy/com/thoughtworks/go/spark/ControllerTrait.groovy
Commit date: 2019-02-01 11:38:30 +0530
Commit message: 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

Diff: diff --git a/.gitignore b/.gitignore
index f530242c9dc..0c442eb7031 100644
--- a/.gitignore
+++ b/.gitignore
@@ -91,10 +91,13 @@ api/api-admins-config-v1/out/
 api/api-admins-config-v1/target/
 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/
 api/api-backups-v1/logs/
 api/api-backups-v1/out/
diff --git a/api/api-access-token-v1/build.gradle b/api/api-access-token-v1/build.gradle
new file mode 100644
index 00000000000..8cb665dfe11
--- /dev/null
+++ b/api/api-access-token-v1/build.gradle
@@ -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
+}
diff --git a/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1.java b/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1.java
new file mode 100644
index 00000000000..7f3526da177
--- /dev/null
+++ b/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1.java
@@ -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();
+    }
+
+}
diff --git a/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenter.java b/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenter.java
new file mode 100644
index 00000000000..7fa79f3afb9
--- /dev/null
+++ b/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenter.java
@@ -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());
+        }
+    }
+}
diff --git a/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenter.java b/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenter.java
new file mode 100644
index 00000000000..9a0b33b79a5
--- /dev/null
+++ b/api/api-access-token-v1/src/main/java/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenter.java
@@ -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)));
+                        })
+                );
+    }
+}
diff --git a/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1Test.groovy b/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1Test.groovy
new file mode 100644
index 00000000000..eec50e4be08
--- /dev/null
+++ b/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/AccessTokenControllerV1Test.groovy
@@ -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!")
+      }
+    }
+  }
+}
diff --git a/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenterTest.groovy b/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenterTest.groovy
new file mode 100644
index 00000000000..27de7c8dfbe
--- /dev/null
+++ b/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokenRepresenterTest.groovy
@@ -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)
+  }
+}
diff --git a/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenterTest.groovy b/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenterTest.groovy
new file mode 100644
index 00000000000..5cec230a8aa
--- /dev/null
+++ b/api/api-access-token-v1/src/test/groovy/com/thoughtworks/go/apiv1/accessToken/representers/AccessTokensRepresenterTest.groovy
@@ -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)
+  }
+}
diff --git a/common/src/main/java/com/thoughtworks/go/domain/AccessToken.java b/common/src/main/java/com/thoughtworks/go/domain/AccessToken.java
new file mode 100644
index 00000000000..6afd98d1864
--- /dev/null
+++ b/common/src/main/java/com/thoughtworks/go/domain/AccessToken.java
@@ -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 + '\'' +
+                '}';
+    }
+}
diff --git a/common/src/test/java/com/thoughtworks/go/helper/AccessTokenMother.java b/common/src/test/java/com/thoughtworks/go/helper/AccessTokenMother.java
new file mode 100644
index 00000000000..bf58e5b7606
--- /dev/null
+++ b/common/src/test/java/com/thoughtworks/go/helper/AccessTokenMother.java
@@ -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;
+    }
+}
diff --git a/server/config/cruise-config.xml b/server/config/cruise-config.xml
index 77ed6a5c86a..27165c0b6f7 100755
--- a/server/config/cruise-config.xml
+++ b/server/config/cruise-config.xml
@@ -12,10 +12,11 @@
       </authConfigs>
       <roles>
         <role name="xyz" />
       </roles>
       <admins>
+        <user>jez</user>
         <user>admin</user>
       </admins>
     </security>
     <backup emailOnSuccess="true" emailOnFailure="true" />
   </server>
diff --git a/server/db/migrate/h2deltas/1902002_add_access_token.sql b/server/db/migrate/h2deltas/1902002_add_access_token.sql
new file mode 100644
index 00000000000..5b929bc443a
--- /dev/null
+++ b/server/db/migrate/h2deltas/1902002_add_access_token.sql
@@ -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;
diff --git a/server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenDao.java b/server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenDao.java
new file mode 100644
index 00000000000..a3fac87cbb1
--- /dev/null
+++ b/server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenDao.java
@@ -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);
+}
diff --git a/server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDao.java b/server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDao.java
new file mode 100644
index 00000000000..fe73e6075ff
--- /dev/null
+++ b/server/src/main/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDao.java
@@ -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();
+            }
+        });
+    }
+}
diff --git a/server/src/main/java/com/thoughtworks/go/server/exceptions/InvalidAccessTokenException.java b/server/src/main/java/com/thoughtworks/go/server/exceptions/InvalidAccessTokenException.java
new file mode 100644
index 00000000000..92a200e14f1
--- /dev/null
+++ b/server/src/main/java/com/thoughtworks/go/server/exceptions/InvalidAccessTokenException.java
@@ -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.");
+    }
+}
diff --git a/server/src/main/java/com/thoughtworks/go/server/exceptions/RevokedAccessTokenException.java b/server/src/main/java/com/thoughtworks/go/server/exceptions/RevokedAccessTokenException.java
new file mode 100644
index 00000000000..3ea9c7026d1
--- /dev/null
+++ b/server/src/main/java/com/thoughtworks/go/server/exceptions/RevokedAccessTokenException.java
@@ -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));
+    }
+}
diff --git a/server/src/main/java/com/thoughtworks/go/server/service/AccessTokenService.java b/server/src/main/java/com/thoughtworks/go/server/service/AccessTokenService.java
new file mode 100644
index 00000000000..6600a8c856d
--- /dev/null
+++ b/server/src/main/java/com/thoughtworks/go/server/service/AccessTokenService.java
@@ -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());
+    }
+}
diff --git a/server/src/main/resources/hibernate-mappings/AccessToken.hbm.xml b/server/src/main/resources/hibernate-mappings/AccessToken.hbm.xml
new file mode 100644
index 00000000000..003f7a419b0
--- /dev/null
+++ b/server/src/main/resources/hibernate-mappings/AccessToken.hbm.xml
@@ -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>
diff --git a/server/src/test-fast/java/com/thoughtworks/go/server/service/AccessTokenServiceTest.java b/server/src/test-fast/java/com/thoughtworks/go/server/service/AccessTokenServiceTest.java
new file mode 100644
index 00000000000..291467852b5
--- /dev/null
+++ b/server/src/test-fast/java/com/thoughtworks/go/server/service/AccessTokenServiceTest.java
@@ -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);
+    }
+}
diff --git a/server/src/test-integration/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDaoIntegrationTest.java b/server/src/test-integration/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDaoIntegrationTest.java
new file mode 100644
index 00000000000..d2d9bf9b03d
--- /dev/null
+++ b/server/src/test-integration/java/com/thoughtworks/go/server/dao/AccessTokenSqlMapDaoIntegrationTest.java
@@ -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();
+    }
+}
diff --git a/server/src/test-integration/java/com/thoughtworks/go/server/service/AccessTokenServiceIntegrationTest.java b/server/src/test-integration/java/com/thoughtworks/go/server/service/AccessTokenServiceIntegrationTest.java
new file mode 100644
index 00000000000..b44e310f8a8
--- /dev/null
+++ b/server/src/test-integration/java/com/thoughtworks/go/server/service/AccessTokenServiceIntegrationTest.java
@@ -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()));
+    }
+}
diff --git a/server/src/test-shared/java/com/thoughtworks/go/server/dao/DatabaseAccessHelper.java b/server/src/test-shared/java/com/thoughtworks/go/server/dao/DatabaseAccessHelper.java
index b90383ee113..d8e70eac0f1 100644
--- a/server/src/test-shared/java/com/thoughtworks/go/server/dao/DatabaseAccessHelper.java
+++ b/server/src/test-shared/java/com/thoughtworks/go/server/dao/DatabaseAccessHelper.java
@@ -187,10 +187,11 @@ public class DatabaseAccessHelper extends HibernateDaoSupport {
         dataSet.addTable(new DefaultTable("stageArtifactCleanupProhibited"));
         dataSet.addTable(new DefaultTable("serverBackups"));
         dataSet.addTable(new DefaultTable("jobAgentMetadata"));
         dataSet.addTable(new DefaultTable("DataSharingSettings"));
         dataSet.addTable(new DefaultTable("UsageDataReporting"));
+        dataSet.addTable(new DefaultTable("AccessToken"));
 
         databaseTester.setDataSet(dataSet);
     }
 
     public void onSetUp() throws Exception {
diff --git a/server/webapp/WEB-INF/applicationContext-global.xml b/server/webapp/WEB-INF/applicationContext-global.xml
index bdd115bd5fb..ae15019b520 100644
--- a/server/webapp/WEB-INF/applicationContext-global.xml
+++ b/server/webapp/WEB-INF/applicationContext-global.xml
@@ -25,10 +25,11 @@
         http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-2.5.xsd"
        default-init-method="init">
 
   <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"/>
   <context:component-scan base-package="com.thoughtworks.go.apiv3.users"/>
   <context:component-scan base-package="com.thoughtworks.go.apiv3.dashboard"/>
diff --git a/server/webapp/WEB-INF/urlrewrite.xml b/server/webapp/WEB-INF/urlrewrite.xml
index c93151c8300..4c314d58665 100644
--- a/server/webapp/WEB-INF/urlrewrite.xml
+++ b/server/webapp/WEB-INF/urlrewrite.xml
@@ -44,10 +44,28 @@
     <condition type="header" name="Accept">application\/vnd\.go\.cd\.v3\+json</condition>
     <from>^/api/users/(.*)$</from>
     <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>
     <run class="com.thoughtworks.go.server.SparkOrRailsToggle" method="usersPageUsingRails"/>
     <to last="true">/%{attribute:sparkOrRails}/admin/users</to>
diff --git a/settings.gradle b/settings.gradle
index 9ceb1e99d3c..74b2fbda370 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -25,10 +25,11 @@ include ':agent-process-launcher'
 include ':api:api-admins-config-v1'
 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'
 include ':api:api-config-repo-operations-v1'
 include ':api:api-config-repos-v1'
diff --git a/spark/spark-base/src/main/java/com/thoughtworks/go/spark/Routes.java b/spark/spark-base/src/main/java/com/thoughtworks/go/spark/Routes.java
index de33def2b7e..4980008213f 100644
--- a/spark/spark-base/src/main/java/com/thoughtworks/go/spark/Routes.java
+++ b/spark/spark-base/src/main/java/com/thoughtworks/go/spark/Routes.java
@@ -431,6 +431,21 @@ 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);
+        }
+    }
 }
diff --git a/spark/spark-base/src/main/java/com/thoughtworks/go/spark/SparkController.java b/spark/spark-base/src/main/java/com/thoughtworks/go/spark/SparkController.java
index 27e6d94c8fa..e7f4974cb14 100644
--- a/spark/spark-base/src/main/java/com/thoughtworks/go/spark/SparkController.java
+++ b/spark/spark-base/src/main/java/com/thoughtworks/go/spark/SparkController.java
@@ -16,16 +16,18 @@
 
 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;
 
 public interface SparkController {
diff --git a/spark/spark-base/src/test/groovy/com/thoughtworks/go/spark/ControllerTrait.groovy b/spark/spark-base/src/test/groovy/com/thoughtworks/go/spark/ControllerTrait.groovy
index 2f7f72062dc..20ff8d9c430 100644
--- a/spark/spark-base/src/test/groovy/com/thoughtworks/go/spark/ControllerTrait.groovy
+++ b/spark/spark-base/src/test/groovy/com/thoughtworks/go/spark/ControllerTrait.groovy
@@ -20,10 +20,12 @@ import com.thoughtworks.go.api.mocks.MockHttpServletResponseAssert
 import com.thoughtworks.go.config.CaseInsensitiveString
 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
 import com.thoughtworks.go.spark.mocks.TestRequestContext
 import com.thoughtworks.go.spark.mocks.TestSparkPreFilter
@@ -127,11 +129,14 @@ trait ControllerTrait<T extends SparkController> {
         httpRequestBuilder.withJsonBody((Object) requestBody)
       }
     }
 
     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()
     request.setParts(parts)
 

Fixed code: api/api-access-token-v1/logs/
api/api-access-token-v1/out/
api/api-access-token-v1/target/
/*
 * 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
}
/*
 * 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();
    }

}
/*
 * 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());
        }
    }
}
/*
 * 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)));
                        })
                );
    }
}
/*
 * 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!")
      }
    }
  }
}
/*
 * 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)
  }
}
/*
 * 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)
  }
}
/*
 * 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 + '\'' +
                '}';
    }
}
/*
 * 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;
    }
}
        <user>jez</user>
--*************************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;
/*
 * 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);
}
/*
 * 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();
            }
        });
    }
}
/*
 * 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.");
    }
}
/*
 * 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));
    }
}
/*
 * 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());
    }
}
<?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>
/*
 * 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);
    }
}
/*
 * 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();
    }
}
/*
 * 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()));
    }
}
        dataSet.addTable(new DefaultTable("AccessToken"));
  <context:component-scan base-package="com.thoughtworks.go.apiv1.accessToken"/>
  <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>

include ':api:api-access-token-v1'

    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);
        }
    }
import com.thoughtworks.go.server.newsecurity.models.AuthenticationToken;
import javax.servlet.http.HttpServletRequest;
import com.thoughtworks.go.server.newsecurity.models.AuthenticationToken
import com.thoughtworks.go.server.newsecurity.models.UsernamePassword
      final AuthenticationToken<?> newToken = new AuthenticationToken<>(SessionUtils.getCurrentUser(), new UsernamePassword(currentUserLoginName().toString(), "password"), "plugin1", System.currentTimeMillis(), "authConfigId")

      httpRequestBuilder.withSessionAttr(SessionUtils.AUTHENTICATION_TOKEN, newToken)