├── .java-version ├── .github ├── pr-labeler.yml └── workflows │ ├── handle_pr_for_http4k_upgrade.yaml │ ├── build.yaml │ └── create_pr_for_http4k_upgrade.yaml ├── settings.gradle ├── src ├── test │ ├── resources │ │ ├── unit │ │ │ ├── WhoIsThereTest.no users.approved │ │ │ ├── ByeByeTest.user is removed.approved │ │ │ ├── KnockKnockTest.user not found.approved │ │ │ ├── KnockKnockTest.user is accepted.approved │ │ │ ├── ByeByeTest.user not found.approved │ │ │ ├── KnockKnockTest.user is already in building.approved │ │ │ └── WhoIsThereTest.there are users.approved │ │ ├── functional │ │ │ ├── WebTest.serves static content.approved │ │ │ ├── ManageUsersTest.add user then delete them.approved │ │ │ ├── ManageUsersTest.manage users requires login via oauth.approved │ │ │ ├── WebTest.homepage.approved │ │ │ └── ManageUsersTest.add user.approved │ │ └── nonfunctional │ │ │ └── OpenApiContractTest.provides API documentation in open api format.approved │ └── kotlin │ │ ├── env │ │ ├── oauthserver │ │ │ ├── UserAuthentication.kt │ │ │ ├── SimpleClientValidator.kt │ │ │ ├── SimpleAccessTokens.kt │ │ │ ├── InMemoryBasedAuthRequestTracking.kt │ │ │ ├── InMemoryAuthorizationCodes.kt │ │ │ └── SimpleOAuthServer.kt │ │ ├── entrylogger │ │ │ └── FakeEntryLogger.kt │ │ ├── RunnableEnvironment.kt │ │ ├── userdirectory │ │ │ └── FakeUserDirectory.kt │ │ └── TestEnvironment.kt │ │ ├── cdc │ │ ├── entrylogger │ │ │ ├── RealEntryLoggerTest.kt │ │ │ ├── FakeEntryLoggerTest.kt │ │ │ └── EntryLoggerContract.kt │ │ └── userdirectory │ │ │ ├── FakeUserDirectoryTest.kt │ │ │ ├── RealUserDirectoryTest.kt │ │ │ └── UserDirectoryContract.kt │ │ ├── nonfunctional │ │ ├── OpenApiContractTest.kt │ │ └── DiagnosticTest.kt │ │ ├── unit │ │ ├── WhoIsThereTest.kt │ │ ├── ByeByeTest.kt │ │ ├── SecuritySystemServerTest.kt │ │ └── KnockKnockTest.kt │ │ └── functional │ │ ├── WebTest.kt │ │ ├── ManageUsersTest.kt │ │ ├── ReportInhabitantsTest.kt │ │ ├── ExitBuildingTest.kt │ │ └── EnteringBuildingTest.kt └── main │ ├── resources │ ├── public │ │ ├── brain.png │ │ ├── openapi │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── index.html │ │ │ └── oauth2-redirect.html │ │ └── style.css │ └── verysecuresystems │ │ └── web │ │ ├── Error.hbs │ │ ├── Index.hbs │ │ └── ListUsersView.hbs │ └── kotlin │ └── verysecuresystems │ ├── diagnostic │ ├── Ping.kt │ ├── Uptime.kt │ ├── Diagnostic.kt │ └── Auditor.kt │ ├── Main.kt │ ├── oauth │ ├── TokenChecker.kt │ ├── SecurityServerAuthProvider.kt │ └── InMemoryOAuthPersistence.kt │ ├── Inhabitants.kt │ ├── domain.kt │ ├── web │ ├── ListUsers.kt │ ├── ShowError.kt │ ├── ShowIndex.kt │ ├── DeleteUser.kt │ ├── Web.kt │ └── CreateUser.kt │ ├── api │ ├── WhoIsThere.kt │ ├── Api.kt │ ├── ByeBye.kt │ └── KnockKnock.kt │ ├── external │ ├── EntryLogger.kt │ └── UserDirectory.kt │ ├── SecuritySystemServer.kt │ └── SecuritySystem.kt ├── gradle.properties ├── .gitignore ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── upgrade_http4k.sh ├── gradlew.bat ├── README.md ├── gradlew └── LICENSE /.java-version: -------------------------------------------------------------------------------- 1 | 21 2 | -------------------------------------------------------------------------------- /.github/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | automerge: ['auto/*'] -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'http4k-by-example' -------------------------------------------------------------------------------- /src/test/resources/unit/WhoIsThereTest.no users.approved: -------------------------------------------------------------------------------- 1 | [ 2 | ] -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | junitVersion=5.13.4 2 | http4kVersion=6.25.0.0 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | .idea/ 3 | *.iml 4 | build/ 5 | .DS_Store 6 | out/ -------------------------------------------------------------------------------- /src/test/resources/unit/ByeByeTest.user is removed.approved: -------------------------------------------------------------------------------- 1 | { 2 | "message": "processing" 3 | } -------------------------------------------------------------------------------- /src/test/resources/unit/KnockKnockTest.user not found.approved: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Unknown user" 3 | } -------------------------------------------------------------------------------- /src/test/resources/unit/KnockKnockTest.user is accepted.approved: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Access granted" 3 | } -------------------------------------------------------------------------------- /src/test/resources/unit/ByeByeTest.user not found.approved: -------------------------------------------------------------------------------- 1 | { 2 | "message": "User is not inside building" 3 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/http4k/http4k-by-example/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/public/brain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/http4k/http4k-by-example/HEAD/src/main/resources/public/brain.png -------------------------------------------------------------------------------- /src/test/resources/unit/KnockKnockTest.user is already in building.approved: -------------------------------------------------------------------------------- 1 | { 2 | "message": "User is already inside building" 3 | } -------------------------------------------------------------------------------- /src/main/resources/public/openapi/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/http4k/http4k-by-example/HEAD/src/main/resources/public/openapi/favicon-16x16.png -------------------------------------------------------------------------------- /src/main/resources/public/openapi/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/http4k/http4k-by-example/HEAD/src/main/resources/public/openapi/favicon-32x32.png -------------------------------------------------------------------------------- /upgrade_http4k.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | NEW_VERSION=$1 5 | 6 | cat gradle.properties | grep -v "http4kVersion" > out.txt 7 | echo "http4kVersion=$NEW_VERSION" >> out.txt 8 | mv out.txt gradle.properties 9 | -------------------------------------------------------------------------------- /src/test/resources/unit/WhoIsThereTest.there are users.approved: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": { 4 | "value": 1 5 | }, 6 | "name": { 7 | "value": "bob" 8 | }, 9 | "email": { 10 | "value": "a@b" 11 | } 12 | } 13 | ] -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/resources/verysecuresystems/web/Error.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Something went wrong...

7 |

{{message}}

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Droid Sans", sans-serif; 3 | } 4 | 5 | .content { 6 | padding: 50px; 7 | } 8 | 9 | .content { 10 | padding: 50px; 11 | } 12 | 13 | .errors { 14 | list-style: none; 15 | color: red; 16 | } 17 | -------------------------------------------------------------------------------- /src/test/kotlin/env/oauthserver/UserAuthentication.kt: -------------------------------------------------------------------------------- 1 | package env.oauthserver 2 | 3 | import org.http4k.core.Credentials 4 | 5 | class UserAuthentication(private vararg val validCredentials: Credentials) { 6 | fun authenticate(credentials: Credentials) = credentials in validCredentials 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/functional/WebTest.serves static content.approved: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Droid Sans", sans-serif; 3 | } 4 | 5 | .content { 6 | padding: 50px; 7 | } 8 | 9 | .content { 10 | padding: 50px; 11 | } 12 | 13 | .errors { 14 | list-style: none; 15 | color: red; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/diagnostic/Ping.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.diagnostic 2 | 3 | import org.http4k.core.Method.GET 4 | import org.http4k.core.Response 5 | import org.http4k.core.Status.Companion.OK 6 | import org.http4k.routing.bind 7 | 8 | fun Ping() = "/ping" bind GET to { Response(OK).body("pong") } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/Main.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems 2 | 3 | import org.http4k.config.Environment 4 | 5 | /** 6 | * Main entry point. Note that this will not run without setting up the correct environmental variables 7 | * - see RunnableEnvironment for a demo-able version of the server. 8 | */ 9 | fun main() { 10 | SecuritySystemServer(Environment.ENV).start() 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/diagnostic/Uptime.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.diagnostic 2 | 3 | import org.http4k.core.Method.GET 4 | import org.http4k.core.Response 5 | import org.http4k.core.Status.Companion.OK 6 | import org.http4k.routing.RoutingHttpHandler 7 | import org.http4k.routing.bind 8 | import java.time.Clock 9 | 10 | fun Uptime(clock: Clock): RoutingHttpHandler { 11 | val startTime = clock.instant().toEpochMilli() 12 | return "/uptime" bind GET to { 13 | Response(OK).body("uptime is: ${(clock.instant().toEpochMilli() - startTime) / 1000}s") 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/oauth/TokenChecker.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.oauth 2 | 3 | import org.http4k.security.AccessToken 4 | 5 | /** 6 | * Checks that the passed BearerToken is authentic. (eg. signed with a particular key) 7 | */ 8 | interface TokenChecker { 9 | fun check(accessToken: AccessToken): Boolean 10 | } 11 | 12 | /** 13 | * We aren't doing any crypto in this example, so just check the key for the prefix. 14 | */ 15 | object InsecureTokenChecker : TokenChecker { 16 | override fun check(accessToken: AccessToken) = accessToken.value.startsWith("ACCESS_TOKEN") 17 | } -------------------------------------------------------------------------------- /src/test/kotlin/cdc/entrylogger/RealEntryLoggerTest.kt: -------------------------------------------------------------------------------- 1 | package cdc.entrylogger 2 | 3 | import org.http4k.client.OkHttp 4 | import org.http4k.core.Uri 5 | import org.http4k.core.then 6 | import org.http4k.filter.ClientFilters 7 | import org.junit.jupiter.api.Disabled 8 | 9 | /** 10 | * Contract implementation for the real user directory service. Extra steps might be required here to setup/teardown 11 | * test data. 12 | */ 13 | @Disabled // this would not be ignored in reality 14 | class RealEntryLoggerTest : EntryLoggerContract { 15 | override val http = ClientFilters.SetHostFrom(Uri.of("http://entrylogger.com")).then(OkHttp()) 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/diagnostic/Diagnostic.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.diagnostic 2 | 3 | import org.http4k.core.Method.GET 4 | import org.http4k.core.Response 5 | import org.http4k.core.Status.Companion.OK 6 | import org.http4k.routing.RoutingHttpHandler 7 | import org.http4k.routing.bind 8 | import org.http4k.routing.routes 9 | import java.time.Clock 10 | 11 | /** 12 | * The internal monitoring API. 13 | */ 14 | fun Diagnostic(clock: Clock): RoutingHttpHandler = "/internal" bind routes( 15 | Ping(), 16 | Uptime(clock), 17 | "/" bind GET to { Response(OK).body("diagnostic module. visit: /ping or /uptime") } 18 | ) 19 | -------------------------------------------------------------------------------- /src/test/resources/functional/ManageUsersTest.add user then delete them.approved: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Known users are:

7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | Add a user: 15 | 18 |
19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/test/resources/functional/ManageUsersTest.manage users requires login via oauth.approved: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Known users are:

7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | Add a user: 15 | 18 |
19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/test/kotlin/cdc/entrylogger/FakeEntryLoggerTest.kt: -------------------------------------------------------------------------------- 1 | package cdc.entrylogger 2 | 3 | import com.natpryce.hamkrest.assertion.assertThat 4 | import com.natpryce.hamkrest.throws 5 | import env.entrylogger.FakeEntryLogger 6 | import org.http4k.cloudnative.RemoteRequestFailed 7 | import org.junit.jupiter.api.Test 8 | import verysecuresystems.Username 9 | 10 | class FakeEntryLoggerTest : EntryLoggerContract { 11 | override val http = FakeEntryLogger() 12 | 13 | // test a behaviour here which is not supported by the "real" server 14 | @Test 15 | fun `username lookup blows up`() { 16 | http.blowsUp() 17 | assertThat({ entryLogger().enter(Username("bob")) }, throws()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/handle_pr_for_http4k_upgrade.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | types: [ labeled ] 4 | 5 | jobs: 6 | handle-pr-for-http4k-upgrade: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Label PR 10 | uses: TimonVS/pr-labeler-action@v5.0.0 11 | with: 12 | configuration-path: .github/pr-labeler.yml 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.ORG_PUBLIC_REPO_RELEASE_TRIGGERING }} 15 | - name: Automatically Merge 16 | uses: plm9606/automerge_actions@1.2.2 17 | with: 18 | label-name: automerge 19 | reviewers-number: 0 20 | merge-method: squash 21 | auto-delete: true 22 | github-token: ${{ secrets.ORG_PUBLIC_REPO_RELEASE_TRIGGERING }} 23 | -------------------------------------------------------------------------------- /src/test/kotlin/nonfunctional/OpenApiContractTest.kt: -------------------------------------------------------------------------------- 1 | package nonfunctional 2 | 3 | import env.TestEnvironment 4 | import org.http4k.core.Method 5 | import org.http4k.core.Request 6 | import org.http4k.core.Status.Companion.OK 7 | import org.http4k.testing.Approver 8 | import org.http4k.testing.JsonApprovalTest 9 | import org.http4k.testing.assertApproved 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.extension.ExtendWith 12 | 13 | @ExtendWith(JsonApprovalTest::class) 14 | class OpenApiContractTest { 15 | private val env = TestEnvironment() 16 | 17 | @Test 18 | fun `provides API documentation in open api format`(approver: Approver) { 19 | approver.assertApproved(env.http(Request(Method.GET, "/api/api-docs")), OK) 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/Inhabitants.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems 2 | 3 | /** 4 | * Simple, in-memory persistence of who is currently inside the building 5 | */ 6 | class Inhabitants : Iterable { 7 | private val currentUsers = mutableListOf() 8 | 9 | fun add(user: Username) = 10 | when (user) { 11 | in currentUsers -> false 12 | else -> { 13 | currentUsers += user 14 | true 15 | } 16 | } 17 | 18 | fun remove(user: Username) = 19 | when (user) { 20 | in currentUsers -> { 21 | currentUsers -= user 22 | true 23 | } 24 | else -> false 25 | } 26 | 27 | override fun iterator(): Iterator = currentUsers.iterator() 28 | } -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - '*' 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4.1.1 13 | - uses: actions/setup-java@v4.0.0 14 | with: 15 | distribution: temurin 16 | java-version: 21 17 | cache: gradle 18 | - name: Build 19 | run: ./gradlew check 20 | - name: Tag automerge branch 21 | if: ${{ github.event_name == 'pull_request' && startsWith(github.event.pull_request.title, 'Auto-upgrade') }} 22 | uses: TimonVS/pr-labeler-action@v5.0.0 23 | with: 24 | configuration-path: .github/pr-labeler.yml 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.ORG_PUBLIC_REPO_RELEASE_TRIGGERING }} 27 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/domain.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems 2 | 3 | import org.http4k.core.Status 4 | import org.http4k.core.Uri 5 | import org.http4k.events.Event 6 | import org.http4k.events.EventCategory 7 | 8 | data class IncomingEvent(val uri: Uri, val status: Status) : Event { 9 | val category = EventCategory("incoming") 10 | } 11 | 12 | data class OutgoingEvent(val uri: Uri, val status: Status) : Event { 13 | val category = EventCategory("outgoing") 14 | } 15 | 16 | data class Id(val value: Int) 17 | 18 | data class Username(val value: String) 19 | 20 | data class EmailAddress(val value: String) 21 | 22 | data class User(val id: Id, val name: Username, val email: EmailAddress) 23 | 24 | data class UserEntry(val username: String, val goingIn: Boolean, val timestamp: Long) 25 | 26 | data class Message(val message: String) 27 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/diagnostic/Auditor.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.diagnostic 2 | 3 | import org.http4k.core.Filter 4 | import org.http4k.events.Events 5 | import verysecuresystems.IncomingEvent 6 | import verysecuresystems.OutgoingEvent 7 | 8 | /** 9 | * This auditor is responsible for logging the performance of inbound calls to the system. 10 | */ 11 | object Auditor { 12 | 13 | /** 14 | * Audit incoming HTTP interactions 15 | */ 16 | fun Incoming(events: Events) = Filter { next -> 17 | { 18 | next(it).apply { events(IncomingEvent(it.uri, status)) } 19 | } 20 | } 21 | 22 | /** 23 | * Audit outgoing HTTP interactions 24 | */ 25 | fun Outgoing(events: Events) = Filter { next -> 26 | { 27 | next(it).apply { events(OutgoingEvent(it.uri, status)) } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/test/kotlin/cdc/userdirectory/FakeUserDirectoryTest.kt: -------------------------------------------------------------------------------- 1 | package cdc.userdirectory 2 | 3 | import com.natpryce.hamkrest.assertion.assertThat 4 | import com.natpryce.hamkrest.throws 5 | import env.userdirectory.FakeUserDirectory 6 | import org.http4k.cloudnative.RemoteRequestFailed 7 | import org.junit.jupiter.api.Test 8 | import verysecuresystems.EmailAddress 9 | import verysecuresystems.Username 10 | 11 | class FakeUserDirectoryTest : UserDirectoryContract { 12 | override val http = FakeUserDirectory() 13 | override val username = Username("ElonMusk") 14 | override val email = EmailAddress("elon@tesla.com") 15 | 16 | // test a behaviour here which is not supported by the "real" server 17 | @Test 18 | fun `list users blows up and return`() { 19 | http.blowsUp() 20 | assertThat({ userDirectory().list() }, throws()) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/cdc/userdirectory/RealUserDirectoryTest.kt: -------------------------------------------------------------------------------- 1 | package cdc.userdirectory 2 | 3 | import org.http4k.client.OkHttp 4 | import org.http4k.core.Uri 5 | import org.http4k.core.then 6 | import org.http4k.filter.ClientFilters 7 | import org.junit.jupiter.api.Disabled 8 | import verysecuresystems.EmailAddress 9 | import verysecuresystems.Username 10 | 11 | /** 12 | * Contract implementation for the real user directory service. Extra steps might be required here to setup/teardown 13 | * test data. 14 | */ 15 | @Disabled // this would not be ignored in reality 16 | class RealUserDirectoryTest : UserDirectoryContract { 17 | // real test data would be set up here for the required environment 18 | override val username = Username("Elon Musk") 19 | override val email = EmailAddress("elon@tesla.com") 20 | 21 | override val http = ClientFilters.SetHostFrom(Uri.of("http://userdirectory.com")).then(OkHttp()) 22 | } 23 | -------------------------------------------------------------------------------- /src/test/kotlin/nonfunctional/DiagnosticTest.kt: -------------------------------------------------------------------------------- 1 | package nonfunctional 2 | 3 | import com.natpryce.hamkrest.and 4 | import com.natpryce.hamkrest.assertion.assertThat 5 | import env.TestEnvironment 6 | import org.http4k.core.Method.GET 7 | import org.http4k.core.Request 8 | import org.http4k.core.Status.Companion.OK 9 | import org.http4k.hamkrest.hasBody 10 | import org.http4k.hamkrest.hasStatus 11 | import org.junit.jupiter.api.Test 12 | 13 | class DiagnosticTest { 14 | 15 | private val env = TestEnvironment() 16 | 17 | @Test 18 | fun `responds to ping`() { 19 | val response = env.http(Request(GET, "/internal/ping")) 20 | assertThat(response, hasStatus(OK).and(hasBody("pong"))) 21 | } 22 | 23 | @Test 24 | fun `responds to uptime`() { 25 | val response = env.http(Request(GET, "/internal/uptime")) 26 | assertThat(response, hasStatus(OK).and(hasBody("uptime is: 0s"))) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/web/ListUsers.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.web 2 | 3 | import org.http4k.core.Method.GET 4 | import org.http4k.core.Response 5 | import org.http4k.core.Status.Companion.OK 6 | import org.http4k.core.then 7 | import org.http4k.lens.WebForm 8 | import org.http4k.routing.bind 9 | import org.http4k.template.TemplateRenderer 10 | import org.http4k.template.ViewModel 11 | import verysecuresystems.User 12 | import verysecuresystems.external.UserDirectory 13 | 14 | /** 15 | * Displays the list of known users using a ViewModel 16 | */ 17 | fun ListUsers(renderer: TemplateRenderer, userDirectory: UserDirectory) = 18 | "/" bind GET to SetHtmlContentType.then { 19 | Response(OK).body(renderer(ListUsersView(userDirectory.list(), WebForm()))) 20 | } 21 | 22 | data class ListUsersView(val users: List, val form: WebForm) : ViewModel { 23 | val errors: List = form.errors.map { it.toString() } 24 | } -------------------------------------------------------------------------------- /src/main/resources/verysecuresystems/web/Index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |

Welcome to Very Secure Systems Ltd

8 |

The time is {{time}}

9 |

Your browser is {{browser}}

10 |

Here is a (OAuth secured) list of known users (rendered with a Handlebars template)

11 |

Here is the auto-generated remote API documentation (using a OpenApi 3 renderer)

12 |

Here is a secured OpenApi 3 UI for interacting with the API (using a StaticModule)

13 |

Here are some diagnostic endpoints

14 |

Here is the Chaos injection interface for the user directory.

15 | 16 |
17 | 18 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/web/ShowError.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.web 2 | 3 | import org.http4k.core.Body 4 | import org.http4k.core.ContentType.Companion.TEXT_HTML 5 | import org.http4k.core.Filter 6 | import org.http4k.core.Response 7 | import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE 8 | import org.http4k.core.with 9 | import org.http4k.template.TemplateRenderer 10 | import org.http4k.template.ViewModel 11 | import org.http4k.template.viewModel 12 | 13 | /** 14 | * Catch all exceptions and shows a nice HTML page instead of a stacktrace 15 | */ 16 | fun ShowError(templates: TemplateRenderer) = Filter { next -> 17 | { 18 | try { 19 | next(it) 20 | } catch (e: Exception) { 21 | Response(SERVICE_UNAVAILABLE).with(Body.viewModel(templates, TEXT_HTML).toLens() of (Error(e))) 22 | } 23 | } 24 | } 25 | 26 | class Error(e: Exception) : ViewModel { 27 | val message = e.localizedMessage 28 | } 29 | -------------------------------------------------------------------------------- /src/test/resources/functional/WebTest.homepage.approved: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |

Welcome to Very Secure Systems Ltd

8 |

The time is 3000-01-01T00:00:00

9 |

Your browser is unknown

10 |

Here is a (OAuth secured) list of known users (rendered with a Handlebars template)

11 |

Here is the auto-generated remote API documentation (using a OpenApi 3 renderer)

12 |

Here is a secured OpenApi 3 UI for interacting with the API (using a StaticModule)

13 |

Here are some diagnostic endpoints

14 |

Here is the Chaos injection interface for the user directory.

15 | 16 |
17 | 18 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/web/ShowIndex.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.web 2 | 3 | import org.http4k.core.Method.GET 4 | import org.http4k.core.Response 5 | import org.http4k.core.Status.Companion.OK 6 | import org.http4k.core.then 7 | import org.http4k.routing.bind 8 | import org.http4k.template.TemplateRenderer 9 | import org.http4k.template.ViewModel 10 | import java.time.Clock 11 | import java.time.LocalDateTime 12 | import java.time.format.DateTimeFormatter 13 | 14 | data class Index(val time: String, val browser: String) : ViewModel 15 | 16 | /** 17 | * The root index page of the server, displayed using a ViewModel. 18 | */ 19 | fun ShowIndex(clock: Clock, renderer: TemplateRenderer) = 20 | "/" bind GET to SetHtmlContentType.then { 21 | Response(OK).body( 22 | renderer( 23 | Index(LocalDateTime.now(clock).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), it.header("User-Agent") 24 | ?: "unknown")) 25 | ) 26 | } -------------------------------------------------------------------------------- /src/test/resources/functional/ManageUsersTest.add user.approved: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Known users are:

7 | 8 | 9 | 10 | 16 | 17 | 18 |
bobemail@email 11 |
12 | 13 | 14 |
15 |
19 | 20 |
21 | Add a user: 22 |
    23 | 24 |
25 |
26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/oauth/SecurityServerAuthProvider.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.oauth 2 | 3 | import org.http4k.core.Credentials 4 | import org.http4k.core.HttpHandler 5 | import org.http4k.core.Uri 6 | import org.http4k.security.OAuthProvider 7 | import org.http4k.security.OAuthProviderConfig 8 | import java.time.Clock 9 | 10 | /** 11 | * OAuthProvider configured to callback to this server. 12 | */ 13 | fun SecurityServerOAuthProvider(securityServerUri: Uri, 14 | oauthServerUri: Uri, 15 | oauthServerHttp: HttpHandler, 16 | clock: Clock) = 17 | OAuthProvider( 18 | OAuthProviderConfig(oauthServerUri, "/", "/oauth2/token", 19 | Credentials("securityServer", "securityServerSecret")), 20 | oauthServerHttp, 21 | securityServerUri.path("/api/oauth/callback"), 22 | emptyList(), 23 | InMemoryOAuthPersistence(clock, InsecureTokenChecker) 24 | ) -------------------------------------------------------------------------------- /.github/workflows/create_pr_for_http4k_upgrade.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | repository_dispatch: 3 | types: [http4k-release] 4 | jobs: 5 | create-pr: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4.1.1 9 | - name: Update release 10 | uses: technote-space/create-pr-action@v2 11 | with: 12 | EXECUTE_COMMANDS: | 13 | curl -s https://raw.githubusercontent.com/http4k/http4k/master/tools/replace_string_match.sh | bash /dev/stdin "gradle.properties" "http4kVersion" "http4kVersion=${{ github.event.client_payload.version }}" 14 | COMMIT_MESSAGE: 'Auto-upgrade to ${{ github.event.client_payload.version }}' 15 | COMMIT_NAME: 'Auto-upgrade to ${{ github.event.client_payload.version }}' 16 | PR_BRANCH_PREFIX: auto/ 17 | PR_BRANCH_NAME: ${{ github.event.client_payload.version }} 18 | PR_TITLE: 'Auto-upgrade to ${{ github.event.client_payload.version }}' 19 | GITHUB_TOKEN: ${{ secrets.ORG_PUBLIC_REPO_RELEASE_TRIGGERING }} 20 | -------------------------------------------------------------------------------- /src/test/kotlin/unit/WhoIsThereTest.kt: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import org.http4k.core.Method.GET 4 | import org.http4k.core.Request 5 | import org.http4k.testing.Approver 6 | import org.http4k.testing.JsonApprovalTest 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.extension.ExtendWith 9 | import verysecuresystems.EmailAddress 10 | import verysecuresystems.Id 11 | import verysecuresystems.User 12 | import verysecuresystems.Username 13 | import verysecuresystems.api.WhoIsThere 14 | 15 | @ExtendWith(JsonApprovalTest::class) 16 | class WhoIsThereTest { 17 | 18 | private val user = User(Id(1), Username("bob"), EmailAddress("a@b")) 19 | 20 | @Test 21 | fun `no users`(approver: Approver) { 22 | val app = WhoIsThere(emptyList()) { user } 23 | approver.assertApproved(app(Request(GET, "/whoIsThere"))) 24 | } 25 | 26 | @Test 27 | fun `there are users`(approver: Approver) { 28 | val app = WhoIsThere(listOf(user.name)) { user } 29 | approver.assertApproved(app(Request(GET, "/whoIsThere"))) 30 | } 31 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/web/DeleteUser.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.web 2 | 3 | import org.http4k.core.Body 4 | import org.http4k.core.Method.POST 5 | import org.http4k.core.Response 6 | import org.http4k.core.Status.Companion.SEE_OTHER 7 | import org.http4k.core.then 8 | import org.http4k.lens.FormField 9 | import org.http4k.lens.Validator.Strict 10 | import org.http4k.lens.int 11 | import org.http4k.lens.webForm 12 | import org.http4k.routing.RoutingHttpHandler 13 | import org.http4k.routing.bind 14 | import verysecuresystems.Id 15 | import verysecuresystems.external.UserDirectory 16 | 17 | /** 18 | * Handles the deletion of an existing user, using standardised http4k form lenses. 19 | */ 20 | fun DeleteUser(userDirectory: UserDirectory): RoutingHttpHandler { 21 | val id = FormField.int().map(::Id, Id::value).required("id") 22 | val form = Body.webForm(Strict, id).toLens() 23 | 24 | return "/delete" bind POST to SetHtmlContentType.then { 25 | userDirectory.delete(id(form(it))) 26 | Response(SEE_OTHER).header("location", "/users") 27 | } 28 | } -------------------------------------------------------------------------------- /src/main/resources/verysecuresystems/web/ListUsersView.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Known users are:

7 | 8 | 9 | {{#users}} 10 | 16 | {{/users}} 17 | 18 |
{{name.value}}{{email.value}} 11 |
12 | 13 | 14 |
15 |
19 | 20 |
21 | Add a user: 22 |
    23 | {{#errors}} 24 |
  • {{.}}
  • 25 | {{/errors}} 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/test/kotlin/unit/ByeByeTest.kt: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import org.http4k.core.Method.POST 4 | import org.http4k.core.Request 5 | import org.http4k.core.Status.Companion.ACCEPTED 6 | import org.http4k.core.Status.Companion.NOT_FOUND 7 | import org.http4k.testing.Approver 8 | import org.http4k.testing.JsonApprovalTest 9 | import org.http4k.testing.assertApproved 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.extension.ExtendWith 12 | import verysecuresystems.UserEntry 13 | import verysecuresystems.api.ByeBye 14 | 15 | @ExtendWith(JsonApprovalTest::class) 16 | class ByeByeTest { 17 | 18 | private val entry = UserEntry("bob", false, 0) 19 | 20 | @Test 21 | fun `user is removed`(approver: Approver) { 22 | val app = ByeBye({ true }, { entry }) 23 | approver.assertApproved(app(Request(POST, "/bye").query("username", "bob")), ACCEPTED) 24 | } 25 | 26 | @Test 27 | fun `user not found`(approver: Approver) { 28 | val app = ByeBye({ false }, { entry }) 29 | approver.assertApproved(app(Request(POST, "/bye").query("username", "bob")), NOT_FOUND) 30 | } 31 | } -------------------------------------------------------------------------------- /src/test/kotlin/functional/WebTest.kt: -------------------------------------------------------------------------------- 1 | package functional 2 | 3 | import env.TestEnvironment 4 | import org.http4k.core.Response 5 | import org.http4k.core.Status.Companion.I_M_A_TEAPOT 6 | import org.http4k.core.Status.Companion.OK 7 | import org.http4k.core.Uri 8 | import org.http4k.testing.ApprovalTest 9 | import org.http4k.testing.Approver 10 | import org.http4k.testing.assertApproved 11 | import org.http4k.webdriver.Http4kWebDriver 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.extension.ExtendWith 14 | 15 | @ExtendWith(ApprovalTest::class) 16 | class WebTest { 17 | private val env = TestEnvironment() 18 | 19 | @Test 20 | fun homepage(approver: Approver) { 21 | approver.assertApproved(env.browser.apply { get(Uri.of("/")) }) 22 | } 23 | 24 | @Test 25 | fun `serves static content`(approver: Approver) { 26 | approver.assertApproved(env.browser.apply { get(Uri.of("/style.css")) }) 27 | } 28 | } 29 | 30 | private fun Approver.assertApproved(browser: Http4kWebDriver) { 31 | assertApproved(Response(browser.status ?: I_M_A_TEAPOT).body(browser.pageSource ?: ""), OK) 32 | } 33 | -------------------------------------------------------------------------------- /src/test/kotlin/cdc/entrylogger/EntryLoggerContract.kt: -------------------------------------------------------------------------------- 1 | package cdc.entrylogger 2 | 3 | import com.natpryce.hamkrest.assertion.assertThat 4 | import com.natpryce.hamkrest.equalTo 5 | import org.http4k.core.HttpHandler 6 | import org.junit.jupiter.api.Test 7 | import verysecuresystems.UserEntry 8 | import verysecuresystems.Username 9 | import verysecuresystems.external.EntryLogger 10 | import java.time.Clock 11 | import java.time.Instant 12 | import java.time.Instant.EPOCH 13 | import java.time.ZoneId 14 | 15 | /** 16 | * This represents the contract that both the real and fake EntryLogger servers will adhere to. 17 | */ 18 | interface EntryLoggerContract { 19 | 20 | val http: HttpHandler 21 | val time: Instant get() = EPOCH 22 | 23 | fun entryLogger() = EntryLogger(http, Clock.fixed(time, ZoneId.systemDefault())) 24 | 25 | @Test 26 | fun `can log a user entry and it is listed`() { 27 | assertThat(entryLogger().enter(Username("bob")), equalTo(UserEntry("bob", true, time.toEpochMilli()))) 28 | assertThat(entryLogger().exit(Username("bob")), equalTo(UserEntry("bob", false, time.toEpochMilli()))) 29 | } 30 | } 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/test/kotlin/env/oauthserver/SimpleClientValidator.kt: -------------------------------------------------------------------------------- 1 | package env.oauthserver 2 | 3 | import org.http4k.core.Credentials 4 | import org.http4k.core.Request 5 | import org.http4k.core.Uri 6 | import org.http4k.security.oauth.server.ClientId 7 | import org.http4k.security.oauth.server.ClientValidator 8 | 9 | data class OAuthClientData(val credentials: Credentials, val redirectionUri: Uri) 10 | 11 | object SimpleClientValidator { 12 | operator fun invoke(vararg clientData: OAuthClientData) = object : ClientValidator { 13 | 14 | override fun validateClientId(request: Request, clientId: ClientId) = clientData.any { clientId.value == it.credentials.user } 15 | 16 | override fun validateCredentials(request: Request, clientId: ClientId, clientSecret: String) = clientData.any { Credentials(clientId.value, clientSecret) == it.credentials } 17 | 18 | override fun validateRedirection(request: Request, clientId: ClientId, redirectionUri: Uri) = clientData.any { validateClientId(request, clientId) && redirectionUri == it.redirectionUri } 19 | 20 | override fun validateScopes(request: Request, clientId: ClientId, scopes: List) = true 21 | } 22 | } -------------------------------------------------------------------------------- /src/test/kotlin/env/oauthserver/SimpleAccessTokens.kt: -------------------------------------------------------------------------------- 1 | package env.oauthserver 2 | 3 | import dev.forkhandles.result4k.Failure 4 | import dev.forkhandles.result4k.Result 5 | import dev.forkhandles.result4k.Success 6 | import org.http4k.security.AccessToken 7 | import org.http4k.security.oauth.server.AccessTokens 8 | import org.http4k.security.oauth.server.AuthorizationCodeAlreadyUsed 9 | import org.http4k.security.oauth.server.ClientId 10 | import org.http4k.security.oauth.server.TokenRequest 11 | import org.http4k.security.oauth.server.UnsupportedGrantType 12 | import org.http4k.security.oauth.server.accesstoken.AuthorizationCodeAccessTokenRequest 13 | 14 | class SimpleAccessTokens : AccessTokens { 15 | override fun create(clientId: ClientId, tokenRequest: TokenRequest) = 16 | Failure(UnsupportedGrantType("client_credentials")) 17 | 18 | override fun create( 19 | clientId: ClientId, 20 | tokenRequest: AuthorizationCodeAccessTokenRequest 21 | ): Result = 22 | Success(AccessToken(ACCESS_TOKEN_PREFIX + tokenRequest.authorizationCode.value.reversed())) 23 | } 24 | 25 | const val ACCESS_TOKEN_PREFIX = "ACCESS_TOKEN_" 26 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/api/WhoIsThere.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.api 2 | 3 | import org.http4k.contract.ContractRoute 4 | import org.http4k.contract.meta 5 | import org.http4k.core.Body 6 | import org.http4k.core.HttpHandler 7 | import org.http4k.core.Method 8 | import org.http4k.core.Response 9 | import org.http4k.core.Status.Companion.OK 10 | import org.http4k.core.with 11 | import org.http4k.format.Jackson.auto 12 | import verysecuresystems.EmailAddress 13 | import verysecuresystems.Id 14 | import verysecuresystems.User 15 | import verysecuresystems.Username 16 | 17 | /** 18 | * Retrieves a list of the users inside the building. 19 | */ 20 | fun WhoIsThere(inhabitants: Iterable, 21 | lookup: (Username) -> User?): ContractRoute { 22 | val users = Body.auto>().toLens() 23 | 24 | val listUsers: HttpHandler = { 25 | Response(OK).with(users of inhabitants.mapNotNull(lookup)) 26 | } 27 | 28 | return "/whoIsThere" meta { 29 | summary = "List current users in the building" 30 | returning(OK, users to listOf(User(Id(1), Username("A user"), EmailAddress("user@bob.com"))), "Inhabitant list") 31 | } bindContract Method.GET to listUsers 32 | } -------------------------------------------------------------------------------- /src/test/kotlin/unit/SecuritySystemServerTest.kt: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import org.http4k.config.Environment 4 | import org.http4k.core.Uri 5 | import org.junit.jupiter.api.Test 6 | import verysecuresystems.SecuritySystemServer 7 | import verysecuresystems.Settings 8 | import verysecuresystems.Settings.ENTRY_LOGGER_URL 9 | import verysecuresystems.Settings.OAUTH_SERVER_URL 10 | import verysecuresystems.Settings.SECURITY_SERVER_URL 11 | import verysecuresystems.Settings.USER_DIRECTORY_URL 12 | 13 | class SecuritySystemServerTest { 14 | 15 | @Test 16 | fun `can start and stop server`() { 17 | val securityServerPort = 9000 18 | val userDirectoryPort = 10000 19 | val entryLoggerPort = 11000 20 | val oauthServerPort = 12000 21 | 22 | val env = Environment.defaults(Settings.PORT of securityServerPort, 23 | SECURITY_SERVER_URL of Uri.of("http://localhost:$securityServerPort"), 24 | USER_DIRECTORY_URL of Uri.of("http://localhost:$userDirectoryPort"), 25 | ENTRY_LOGGER_URL of Uri.of("http://localhost:$entryLoggerPort"), 26 | OAUTH_SERVER_URL of Uri.of("http://localhost:$oauthServerPort") 27 | ) 28 | 29 | SecuritySystemServer(env).start().stop() 30 | } 31 | } -------------------------------------------------------------------------------- /src/test/kotlin/env/oauthserver/InMemoryBasedAuthRequestTracking.kt: -------------------------------------------------------------------------------- 1 | package env.oauthserver 2 | 3 | import org.http4k.core.Request 4 | import org.http4k.core.Response 5 | import org.http4k.lens.LensFailure 6 | import org.http4k.security.oauth.server.AuthRequest 7 | import org.http4k.security.oauth.server.AuthRequestTracking 8 | import org.http4k.security.oauth.server.OAuthServer 9 | 10 | class InMemoryAuthRequestTracking : AuthRequestTracking { 11 | private val inFlightRequests = mutableListOf() 12 | 13 | override fun trackAuthRequest(request: Request, authRequest: AuthRequest, response: Response) = 14 | response.also { inFlightRequests += authRequest } 15 | 16 | override fun resolveAuthRequest(request: Request) = 17 | try { 18 | with(OAuthServer) { 19 | val extracted = AuthRequest( 20 | clientIdQueryParameter(request), 21 | scopesQueryParameter(request) ?: listOf(), 22 | redirectUriQueryParameter(request), 23 | state(request), 24 | responseType(request) 25 | ) 26 | if (inFlightRequests.remove(extracted)) extracted else null 27 | } 28 | } catch (e: LensFailure) { 29 | null 30 | } 31 | } -------------------------------------------------------------------------------- /src/test/kotlin/env/oauthserver/InMemoryAuthorizationCodes.kt: -------------------------------------------------------------------------------- 1 | package env.oauthserver 2 | 3 | import dev.forkhandles.result4k.Success 4 | import org.http4k.core.Request 5 | import org.http4k.core.Response 6 | import org.http4k.security.oauth.server.AuthRequest 7 | import org.http4k.security.oauth.server.AuthorizationCode 8 | import org.http4k.security.oauth.server.AuthorizationCodeDetails 9 | import org.http4k.security.oauth.server.AuthorizationCodes 10 | import java.time.Clock 11 | import java.time.temporal.ChronoUnit.DAYS 12 | import java.util.UUID 13 | 14 | class InMemoryAuthorizationCodes(private val clock: Clock) : AuthorizationCodes { 15 | private val inFlightCodes = mutableMapOf() 16 | 17 | override fun detailsFor(code: AuthorizationCode): AuthorizationCodeDetails = 18 | inFlightCodes[code]?.also { 19 | inFlightCodes -= code 20 | } ?: error("code not stored") 21 | 22 | override fun create(request: Request, authRequest: AuthRequest, response: Response) = 23 | Success(AuthorizationCode(UUID.randomUUID().toString()).also { 24 | inFlightCodes[it] = AuthorizationCodeDetails(authRequest.client, authRequest.redirectUri!!, clock.instant().plus(1, DAYS), null, false, 25 | authRequest.responseType 26 | ) 27 | }) 28 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/web/Web.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.web 2 | 3 | import org.http4k.core.ContentType.Companion.TEXT_HTML 4 | import org.http4k.core.Filter 5 | import org.http4k.core.then 6 | import org.http4k.core.with 7 | import org.http4k.lens.Header.CONTENT_TYPE 8 | import org.http4k.routing.RoutingHttpHandler 9 | import org.http4k.routing.bind 10 | import org.http4k.routing.routes 11 | import org.http4k.security.OAuthProvider 12 | import org.http4k.template.HandlebarsTemplates 13 | import verysecuresystems.external.UserDirectory 14 | import java.time.Clock 15 | 16 | val SetHtmlContentType = Filter { next -> 17 | { next(it).with(CONTENT_TYPE of TEXT_HTML) } 18 | } 19 | 20 | /** 21 | * Defines the web content layer of the app, including the OAuth-protected 22 | * user management UI. 23 | */ 24 | fun Web(clock: Clock, oAuthProvider: OAuthProvider, userDirectory: UserDirectory): RoutingHttpHandler { 25 | val templates = HandlebarsTemplates().CachingClasspath() 26 | 27 | return ShowError(templates) 28 | .then( 29 | routes( 30 | oAuthProvider.authFilter.then( 31 | "/users" bind routes( 32 | DeleteUser(userDirectory), 33 | CreateUser(templates, userDirectory), 34 | ListUsers(templates, userDirectory) 35 | ) 36 | ), 37 | ShowIndex(clock, templates) 38 | ) 39 | ) 40 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/external/EntryLogger.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.external 2 | 3 | import org.http4k.core.Body 4 | import org.http4k.core.HttpHandler 5 | import org.http4k.core.Method.POST 6 | import org.http4k.core.Request 7 | import org.http4k.core.then 8 | import org.http4k.core.with 9 | import org.http4k.filter.ClientFilters 10 | import org.http4k.filter.HandleRemoteRequestFailed 11 | import org.http4k.format.Jackson.auto 12 | import verysecuresystems.UserEntry 13 | import verysecuresystems.Username 14 | import java.time.Clock 15 | 16 | /** 17 | * Business abstraction for the external EntryLogger service. Uses JSON-automarshalling via 18 | * Jackson to convert objects to Kotlin data-class instances 19 | */ 20 | class EntryLogger(http: HttpHandler, private val clock: Clock) { 21 | 22 | // this filter will handle and rethrow non-successful HTTP responses 23 | private val http = ClientFilters.HandleRemoteRequestFailed().then(http) 24 | 25 | private val userEntry = Body.auto().toLens() 26 | 27 | fun enter(username: Username): UserEntry = 28 | userEntry( 29 | http(Request(POST, "/entry") 30 | .with(userEntry of UserEntry(username.value, true, clock.instant().toEpochMilli())) 31 | ) 32 | ) 33 | 34 | fun exit(username: Username): UserEntry = 35 | userEntry( 36 | http( 37 | Request(POST, "/exit") 38 | .with(userEntry of UserEntry(username.value, false, clock.instant().toEpochMilli())) 39 | ) 40 | ) 41 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/SecuritySystemServer.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems 2 | 3 | import org.http4k.client.OkHttp 4 | import org.http4k.config.Environment 5 | import org.http4k.config.EnvironmentKey 6 | import org.http4k.events.AutoMarshallingEvents 7 | import org.http4k.format.Jackson 8 | import org.http4k.lens.int 9 | import org.http4k.lens.uri 10 | import org.http4k.server.Undertow 11 | import org.http4k.server.asServer 12 | import verysecuresystems.Settings.ENTRY_LOGGER_URL 13 | import verysecuresystems.Settings.OAUTH_SERVER_URL 14 | import verysecuresystems.Settings.PORT 15 | import verysecuresystems.Settings.SECURITY_SERVER_URL 16 | import verysecuresystems.Settings.USER_DIRECTORY_URL 17 | import java.time.Clock 18 | 19 | /** 20 | * Responsible for setting up real HTTP servers and clients to downstream services via HTTP 21 | */ 22 | fun SecuritySystemServer(env: Environment) = 23 | SecuritySystem(Clock.systemUTC(), AutoMarshallingEvents(Jackson), 24 | OkHttp(), 25 | SECURITY_SERVER_URL(env), 26 | OAUTH_SERVER_URL(env), 27 | USER_DIRECTORY_URL(env), 28 | ENTRY_LOGGER_URL(env) 29 | ).asServer(Undertow(PORT(env))) 30 | 31 | object Settings { 32 | val PORT = EnvironmentKey.int().required("PORT") 33 | val SECURITY_SERVER_URL = EnvironmentKey.uri().required("SECURITY_SERVER_URL") 34 | val OAUTH_SERVER_URL = EnvironmentKey.uri().required("OAUTH_SERVER_URL") 35 | val USER_DIRECTORY_URL = EnvironmentKey.uri().required("USER_DIRECTORY_URL") 36 | val ENTRY_LOGGER_URL = EnvironmentKey.uri().required("ENTRY_LOGGER_URL") 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/api/Api.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.api 2 | 3 | import org.http4k.contract.contract 4 | import org.http4k.contract.openapi.ApiInfo 5 | import org.http4k.contract.openapi.v3.OpenApi3 6 | import org.http4k.core.Method.GET 7 | import org.http4k.routing.RoutingHttpHandler 8 | import org.http4k.routing.bind 9 | import org.http4k.routing.routes 10 | import org.http4k.security.AuthCodeOAuthSecurity 11 | import org.http4k.security.OAuthProvider 12 | import verysecuresystems.Inhabitants 13 | import verysecuresystems.external.EntryLogger 14 | import verysecuresystems.external.UserDirectory 15 | 16 | /** 17 | * The exposed Server API, protected by Bearer Token (which can be retrieved via 18 | * OAuth login). 19 | */ 20 | fun Api(userDirectory: UserDirectory, 21 | entryLogger: EntryLogger, 22 | inhabitants: Inhabitants, 23 | oAuthProvider: OAuthProvider 24 | ): RoutingHttpHandler = 25 | "/api" bind routes( 26 | "/oauth/callback" bind GET to oAuthProvider.callback, 27 | contract { 28 | renderer = OpenApi3(ApiInfo("Security Server API", "v1.0", "This API is secured by an OAuth auth code. Simply click 'Authorize' to start the flow.")) 29 | descriptionPath = "/api-docs" 30 | security = AuthCodeOAuthSecurity(oAuthProvider) 31 | routes += KnockKnock(userDirectory::lookup, inhabitants::add, entryLogger::enter) 32 | routes += WhoIsThere(inhabitants, userDirectory::lookup) 33 | routes += ByeBye(inhabitants::remove, entryLogger::exit) 34 | } 35 | ) 36 | 37 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/web/CreateUser.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.web 2 | 3 | import org.http4k.core.Body 4 | import org.http4k.core.Method.POST 5 | import org.http4k.core.Response 6 | import org.http4k.core.Status.Companion.OK 7 | import org.http4k.core.Status.Companion.SEE_OTHER 8 | import org.http4k.core.then 9 | import org.http4k.lens.FormField 10 | import org.http4k.lens.Validator.Feedback 11 | import org.http4k.lens.nonEmptyString 12 | import org.http4k.lens.webForm 13 | import org.http4k.routing.RoutingHttpHandler 14 | import org.http4k.routing.bind 15 | import org.http4k.template.TemplateRenderer 16 | import verysecuresystems.EmailAddress 17 | import verysecuresystems.Username 18 | import verysecuresystems.external.UserDirectory 19 | 20 | /** 21 | * Handles the creation of a new user, using standardised http4k form lenses 22 | * configured to feedback errors to the user. 23 | */ 24 | fun CreateUser(renderer: TemplateRenderer, userDirectory: UserDirectory): RoutingHttpHandler { 25 | val username = FormField.nonEmptyString().map(::Username, Username::value).required("username") 26 | val email = FormField.nonEmptyString().map(::EmailAddress, EmailAddress::value).required("email") 27 | val form = Body.webForm(Feedback, username, email).toLens() 28 | 29 | return "/create" bind POST to SetHtmlContentType.then { 30 | val webForm = form(it) 31 | if (webForm.errors.isEmpty()) { 32 | userDirectory.create(username(webForm), email(webForm)) 33 | Response(SEE_OTHER).header("location", "/users") 34 | } else { 35 | Response(OK).body(renderer(ListUsersView(userDirectory.list(), webForm))) 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/test/kotlin/unit/KnockKnockTest.kt: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import org.http4k.core.Method.POST 4 | import org.http4k.core.Request 5 | import org.http4k.core.Status.Companion.ACCEPTED 6 | import org.http4k.core.Status.Companion.CONFLICT 7 | import org.http4k.core.Status.Companion.NOT_FOUND 8 | import org.http4k.testing.Approver 9 | import org.http4k.testing.JsonApprovalTest 10 | import org.http4k.testing.assertApproved 11 | import org.junit.jupiter.api.Test 12 | import org.junit.jupiter.api.extension.ExtendWith 13 | import verysecuresystems.EmailAddress 14 | import verysecuresystems.Id 15 | import verysecuresystems.User 16 | import verysecuresystems.UserEntry 17 | import verysecuresystems.Username 18 | import verysecuresystems.api.KnockKnock 19 | 20 | @ExtendWith(JsonApprovalTest::class) 21 | class KnockKnockTest { 22 | 23 | private val user = User(Id(1), Username("bob"), EmailAddress("a@b")) 24 | private val entry = UserEntry("bob", true, 0) 25 | 26 | @Test 27 | fun `user is accepted`(approver: Approver) { 28 | val app = KnockKnock({ user }, { true }, { entry }) 29 | approver.assertApproved(app(Request(POST, "/knock").query("username", "bob")), ACCEPTED) 30 | } 31 | 32 | @Test 33 | fun `user not found`(approver: Approver) { 34 | val app = KnockKnock({ null }, { true }, { entry }) 35 | approver.assertApproved(app(Request(POST, "/knock").query("username", "bob")), NOT_FOUND) 36 | } 37 | 38 | @Test 39 | fun `user is already in building`(approver: Approver) { 40 | val app = KnockKnock({ user }, { false }, { entry }) 41 | approver.assertApproved(app(Request(POST, "/knock").query("username", "bob")), CONFLICT) 42 | } 43 | } -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/api/ByeBye.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.api 2 | 3 | import org.http4k.contract.ContractRoute 4 | import org.http4k.contract.meta 5 | import org.http4k.core.Body 6 | import org.http4k.core.HttpHandler 7 | import org.http4k.core.Method.POST 8 | import org.http4k.core.Response 9 | import org.http4k.core.Status.Companion.ACCEPTED 10 | import org.http4k.core.Status.Companion.NOT_FOUND 11 | import org.http4k.core.Status.Companion.UNAUTHORIZED 12 | import org.http4k.core.with 13 | import org.http4k.format.Jackson.auto 14 | import org.http4k.lens.Query 15 | import verysecuresystems.Message 16 | import verysecuresystems.UserEntry 17 | import verysecuresystems.Username 18 | 19 | /** 20 | * Allows users to exit the building, but only if they are already inside. 21 | */ 22 | fun ByeBye(removeUser: (Username) -> Boolean, 23 | entryLogger: (Username) -> UserEntry): ContractRoute { 24 | val username = Query.map(::Username).required("username") 25 | val message = Body.auto().toLens() 26 | 27 | val userExit: HttpHandler = { 28 | val exiting = username(it) 29 | if (removeUser(exiting)) { 30 | entryLogger(exiting) 31 | Response(ACCEPTED).with(message of Message("processing")) 32 | } else Response(NOT_FOUND).with(message of Message("User is not inside building")) 33 | } 34 | 35 | return "/bye" meta { 36 | summary = "User exits the building" 37 | queries += username 38 | returning(ACCEPTED, message to Message("processing")) 39 | returning(NOT_FOUND, message to Message("User is not inside building")) 40 | returning(UNAUTHORIZED to "Incorrect key") 41 | } bindContract POST to userExit 42 | } 43 | -------------------------------------------------------------------------------- /src/test/kotlin/cdc/userdirectory/UserDirectoryContract.kt: -------------------------------------------------------------------------------- 1 | package cdc.userdirectory 2 | 3 | import com.natpryce.hamkrest.absent 4 | import com.natpryce.hamkrest.assertion.assertThat 5 | import com.natpryce.hamkrest.equalTo 6 | import org.http4k.core.HttpHandler 7 | import org.junit.jupiter.api.Test 8 | import verysecuresystems.EmailAddress 9 | import verysecuresystems.Username 10 | import verysecuresystems.external.UserDirectory 11 | 12 | /** 13 | * This represents the contract that both the real and fake EntryLogger servers will adhere to. 14 | */ 15 | interface UserDirectoryContract { 16 | val http: HttpHandler 17 | val username: Username 18 | val email: EmailAddress 19 | 20 | fun userDirectory() = UserDirectory(http) 21 | 22 | @Test 23 | fun `is empty initially`() { 24 | assertThat(userDirectory().lookup(username), absent()) 25 | assertThat(userDirectory().list(), equalTo(listOf())) 26 | } 27 | 28 | @Test 29 | fun `can create a user`() { 30 | val created = userDirectory().create(username, email) 31 | assertThat(created.name, equalTo(username)) 32 | assertThat(created.email, equalTo(email)) 33 | } 34 | 35 | @Test 36 | fun `can lookup a user by username`() { 37 | val created = userDirectory().create(username, email) 38 | assertThat(userDirectory().lookup(username), equalTo(created)) 39 | } 40 | 41 | @Test 42 | fun `can list users`() { 43 | val created = userDirectory().create(username, email) 44 | assertThat(userDirectory().list(), equalTo(listOf(created))) 45 | } 46 | 47 | @Test 48 | fun `can delete user`() { 49 | val created = userDirectory().create(username, email) 50 | userDirectory().delete(created.id) 51 | assertThat(userDirectory().lookup(username), absent()) 52 | assertThat(userDirectory().list(), equalTo(listOf())) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/resources/public/openapi/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Swagger UI 7 | 8 | 9 | 10 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/test/kotlin/functional/ManageUsersTest.kt: -------------------------------------------------------------------------------- 1 | package functional 2 | 3 | import env.TestEnvironment 4 | import env.logIn 5 | import org.http4k.core.Response 6 | import org.http4k.core.Status.Companion.I_M_A_TEAPOT 7 | import org.http4k.core.Status.Companion.OK 8 | import org.http4k.testing.ApprovalTest 9 | import org.http4k.testing.Approver 10 | import org.http4k.testing.assertApproved 11 | import org.http4k.webdriver.Http4kWebDriver 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.extension.ExtendWith 14 | import org.openqa.selenium.By 15 | 16 | @ExtendWith(ApprovalTest::class) 17 | class ManageUsersTest { 18 | private val env = TestEnvironment() 19 | 20 | @Test 21 | fun `manage users requires login via oauth`(approver: Approver) { 22 | approver.assertApproved(env.browser { 23 | logIn() 24 | }) 25 | } 26 | 27 | @Test 28 | fun `add user`(approver: Approver) { 29 | approver.assertApproved(env.browser { 30 | logIn() 31 | addUser() 32 | }) 33 | } 34 | 35 | @Test 36 | fun `add user then delete them`(approver: Approver) { 37 | approver.assertApproved(env.browser { 38 | logIn() 39 | addUser() 40 | deleteUser() 41 | }) 42 | } 43 | 44 | private fun Http4kWebDriver.addUser() { 45 | findElement(By.id("username"))?.sendKeys("bob") 46 | findElement(By.id("email"))?.sendKeys("email@email") 47 | findElement(By.id("createUserForm"))?.apply { submit() } 48 | } 49 | 50 | private fun Http4kWebDriver.deleteUser() { 51 | findElement(By.id("deleteUserForm"))?.apply { submit() } 52 | } 53 | } 54 | 55 | private operator fun Http4kWebDriver.invoke(fn: Http4kWebDriver.() -> Unit): Http4kWebDriver = apply(fn) 56 | 57 | private fun Approver.assertApproved(browser: Http4kWebDriver) { 58 | assertApproved(Response(browser.status ?: I_M_A_TEAPOT).body(browser.pageSource ?: ""), OK) 59 | } 60 | -------------------------------------------------------------------------------- /src/test/kotlin/functional/ReportInhabitantsTest.kt: -------------------------------------------------------------------------------- 1 | package functional 2 | 3 | import com.natpryce.hamkrest.assertion.assertThat 4 | import com.natpryce.hamkrest.equalTo 5 | import env.TestEnvironment 6 | import env.checkInhabitants 7 | import env.enterBuilding 8 | import env.exitBuilding 9 | import env.oauthserver.ACCESS_TOKEN_PREFIX 10 | import org.http4k.core.Body 11 | import org.http4k.core.Status.Companion.OK 12 | import org.http4k.core.Status.Companion.TEMPORARY_REDIRECT 13 | import org.http4k.format.Jackson.auto 14 | import org.http4k.hamkrest.hasStatus 15 | import org.http4k.security.AccessToken 16 | import org.junit.jupiter.api.Test 17 | import verysecuresystems.EmailAddress 18 | import verysecuresystems.Id 19 | import verysecuresystems.User 20 | import verysecuresystems.Username 21 | import java.util.UUID 22 | 23 | class ReportInhabitantsTest { 24 | 25 | private val env = TestEnvironment() 26 | private val inhabitants = Body.auto>().toLens() 27 | 28 | @Test 29 | fun `who is there endpoint is protected with oauth token`() { 30 | assertThat(env.checkInhabitants(null).status, equalTo(TEMPORARY_REDIRECT)) 31 | } 32 | 33 | @Test 34 | fun `initially there is no-one inside`() { 35 | val checkInhabitants = env.checkInhabitants(AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID())) 36 | assertThat(checkInhabitants, hasStatus(OK)) 37 | assertThat(inhabitants(checkInhabitants), equalTo(listOf())) 38 | } 39 | 40 | @Test 41 | fun `when a user enters the building`() { 42 | val user = User(Id(1), Username("Bob"), EmailAddress("bob@bob.com")) 43 | 44 | env.userDirectory.contains(user) 45 | 46 | val accessToken = AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID()) 47 | 48 | env.enterBuilding("Bob", accessToken) 49 | assertThat(inhabitants(env.checkInhabitants(accessToken)), equalTo(listOf(user))) 50 | 51 | env.exitBuilding("Bob", accessToken) 52 | assertThat(inhabitants(env.checkInhabitants(accessToken)), equalTo(listOf())) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/api/KnockKnock.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.api 2 | 3 | import org.http4k.contract.ContractRoute 4 | import org.http4k.contract.meta 5 | import org.http4k.core.Body 6 | import org.http4k.core.HttpHandler 7 | import org.http4k.core.Method.POST 8 | import org.http4k.core.Response 9 | import org.http4k.core.Status.Companion.ACCEPTED 10 | import org.http4k.core.Status.Companion.CONFLICT 11 | import org.http4k.core.Status.Companion.NOT_FOUND 12 | import org.http4k.core.Status.Companion.UNAUTHORIZED 13 | import org.http4k.core.with 14 | import org.http4k.format.Jackson.auto 15 | import org.http4k.lens.Query 16 | import verysecuresystems.Message 17 | import verysecuresystems.User 18 | import verysecuresystems.UserEntry 19 | import verysecuresystems.Username 20 | 21 | /** 22 | * Allows users to enter the building, but only if they exist. 23 | */ 24 | fun KnockKnock(lookup: (Username) -> User?, 25 | add: (Username) -> Boolean, 26 | entryLogger: (Username) -> UserEntry): ContractRoute { 27 | val username = Query.map(::Username).required("username") 28 | val message = Body.auto().toLens() 29 | 30 | val userEntry: HttpHandler = { 31 | lookup(username(it))?.name 32 | ?.let { 33 | if (add(it)) { 34 | entryLogger(it) 35 | Response(ACCEPTED).with(message of Message("Access granted")) 36 | } else { 37 | Response(CONFLICT).with(message of Message("User is already inside building")) 38 | } 39 | } 40 | ?: Response(NOT_FOUND).with(message of Message("Unknown user")) 41 | } 42 | 43 | return "/knock" meta { 44 | queries += username 45 | summary = "User enters the building" 46 | returning(ACCEPTED, message to Message("Access granted")) 47 | returning(NOT_FOUND, message to Message("Unknown user")) 48 | returning(CONFLICT, message to Message("User is already inside building")) 49 | returning(UNAUTHORIZED to "Incorrect key") 50 | } bindContract POST to userEntry 51 | } -------------------------------------------------------------------------------- /src/test/kotlin/env/entrylogger/FakeEntryLogger.kt: -------------------------------------------------------------------------------- 1 | package env.entrylogger 2 | 3 | import org.http4k.chaos.ChaosBehaviours.ReturnStatus 4 | import org.http4k.chaos.ChaosEngine 5 | import org.http4k.chaos.withChaosApi 6 | import org.http4k.core.Body 7 | import org.http4k.core.HttpHandler 8 | import org.http4k.core.Method.GET 9 | import org.http4k.core.Method.POST 10 | import org.http4k.core.Request 11 | import org.http4k.core.Response 12 | import org.http4k.core.Status.Companion.ACCEPTED 13 | import org.http4k.core.Status.Companion.CREATED 14 | import org.http4k.core.Status.Companion.I_M_A_TEAPOT 15 | import org.http4k.core.with 16 | import org.http4k.format.Jackson.auto 17 | import org.http4k.routing.bind 18 | import org.http4k.routing.routes 19 | import verysecuresystems.UserEntry 20 | 21 | class FakeEntryLogger : HttpHandler { 22 | 23 | private val engine = ChaosEngine() 24 | 25 | val entries = mutableListOf() 26 | 27 | fun blowsUp() { 28 | engine.enable(ReturnStatus(I_M_A_TEAPOT)) 29 | } 30 | 31 | private fun list(): HttpHandler { 32 | val userEntry = Body.auto().toLens() 33 | return { req -> 34 | val entry = userEntry(req) 35 | entries += entry 36 | Response(CREATED).with(userEntry of entry) 37 | } 38 | } 39 | 40 | private fun entry(): HttpHandler { 41 | val userEntry = Body.auto().toLens() 42 | return { req -> 43 | val entry = userEntry(req) 44 | entries += entry 45 | Response(CREATED).with(userEntry of entry) 46 | } 47 | } 48 | 49 | private fun exit(): HttpHandler { 50 | val userEntry = Body.auto().toLens() 51 | return { req -> 52 | val entry = userEntry(req) 53 | entries += entry 54 | Response(ACCEPTED).with(userEntry of entry) 55 | } 56 | } 57 | 58 | private val app = routes( 59 | "/list" bind GET to list(), 60 | "/entry" bind POST to entry(), 61 | "/exit" bind POST to exit() 62 | ).withChaosApi(engine) 63 | 64 | override fun invoke(p1: Request) = app(p1) 65 | } 66 | -------------------------------------------------------------------------------- /src/test/kotlin/functional/ExitBuildingTest.kt: -------------------------------------------------------------------------------- 1 | package functional 2 | 3 | import com.natpryce.hamkrest.assertion.assertThat 4 | import com.natpryce.hamkrest.equalTo 5 | import env.TestEnvironment 6 | import env.enterBuilding 7 | import env.exitBuilding 8 | import env.oauthserver.ACCESS_TOKEN_PREFIX 9 | import org.http4k.core.Status.Companion.ACCEPTED 10 | import org.http4k.core.Status.Companion.BAD_REQUEST 11 | import org.http4k.core.Status.Companion.NOT_FOUND 12 | import org.http4k.core.Status.Companion.TEMPORARY_REDIRECT 13 | import org.http4k.hamkrest.hasStatus 14 | import org.http4k.security.AccessToken 15 | import org.junit.jupiter.api.Test 16 | import verysecuresystems.EmailAddress 17 | import verysecuresystems.Id 18 | import verysecuresystems.User 19 | import verysecuresystems.UserEntry 20 | import verysecuresystems.Username 21 | import java.util.UUID 22 | 23 | class ExitingBuildingTest { 24 | 25 | private val env = TestEnvironment() 26 | 27 | @Test 28 | fun `rejects missing username in exit endpoint`() { 29 | assertThat(env.exitBuilding(null, AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID())), hasStatus(BAD_REQUEST)) 30 | } 31 | 32 | @Test 33 | fun `exit endpoint is protected with oauth token`() { 34 | assertThat(env.exitBuilding("Bob", null), hasStatus(TEMPORARY_REDIRECT)) 35 | } 36 | 37 | @Test 38 | fun `allows known user to exit and logs entry and exit`() { 39 | env.userDirectory.contains(User(Id(1), Username("Bob"), EmailAddress("bob@bob.com"))) 40 | 41 | val accessToken = AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID()) 42 | 43 | assertThat(env.enterBuilding("Bob", accessToken), hasStatus(ACCEPTED)) 44 | assertThat(env.exitBuilding("Bob", accessToken), hasStatus(ACCEPTED)) 45 | assertThat(env.entryLogger.entries, equalTo(listOf( 46 | UserEntry("Bob", true, env.clock.millis()), 47 | UserEntry("Bob", false, env.clock.millis()) 48 | ))) 49 | } 50 | 51 | @Test 52 | fun `does not allow exit when not in building`() { 53 | env.userDirectory.contains(User(Id(1), Username("Bob"), EmailAddress("bob@bob.com"))) 54 | assertThat(env.exitBuilding("Bob", AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID())), hasStatus(NOT_FOUND)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/kotlin/env/RunnableEnvironment.kt: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import env.entrylogger.FakeEntryLogger 4 | import env.oauthserver.OAuthClientData 5 | import env.oauthserver.SimpleOAuthServer 6 | import env.userdirectory.FakeUserDirectory 7 | import org.http4k.config.Environment 8 | import org.http4k.core.Credentials 9 | import org.http4k.core.Uri 10 | import org.http4k.server.SunHttp 11 | import org.http4k.server.asServer 12 | import verysecuresystems.EmailAddress 13 | import verysecuresystems.Id 14 | import verysecuresystems.SecuritySystemServer 15 | import verysecuresystems.Settings.ENTRY_LOGGER_URL 16 | import verysecuresystems.Settings.OAUTH_SERVER_URL 17 | import verysecuresystems.Settings.PORT 18 | import verysecuresystems.Settings.SECURITY_SERVER_URL 19 | import verysecuresystems.Settings.USER_DIRECTORY_URL 20 | import verysecuresystems.User 21 | import verysecuresystems.Username 22 | 23 | fun main() { 24 | val securityServerPort = 9000 25 | val userDirectoryPort = 10000 26 | val entryLoggerPort = 11000 27 | val oauthServerPort = 12000 28 | 29 | FakeUserDirectory().apply { 30 | contains(User(Id(0), Username("Bob"), EmailAddress("bob@bob.com"))) 31 | contains(User(Id(1), Username("Rita"), EmailAddress("rita@bob.com"))) 32 | contains(User(Id(2), Username("Sue"), EmailAddress("sue@bob.com"))) 33 | }.asServer(SunHttp(userDirectoryPort)).start() 34 | 35 | FakeEntryLogger().asServer(SunHttp(entryLoggerPort)).start() 36 | 37 | SimpleOAuthServer( 38 | Credentials("user", "password"), 39 | OAuthClientData(Credentials("securityServer", "securityServerSecret"), 40 | Uri.of("http://localhost:$securityServerPort/openapi/oauth2-redirect.html") 41 | ), 42 | OAuthClientData(Credentials("securityServer", "securityServerSecret"), 43 | Uri.of("http://localhost:$securityServerPort/api/oauth/callback") 44 | ) 45 | ).asServer(SunHttp(oauthServerPort)).start() 46 | 47 | val env = Environment.defaults(PORT of securityServerPort, 48 | SECURITY_SERVER_URL of Uri.of("http://localhost:$securityServerPort"), 49 | USER_DIRECTORY_URL of Uri.of("http://localhost:$userDirectoryPort"), 50 | ENTRY_LOGGER_URL of Uri.of("http://localhost:$entryLoggerPort"), 51 | OAUTH_SERVER_URL of Uri.of("http://localhost:$oauthServerPort") 52 | ) 53 | SecuritySystemServer(env).start() 54 | } 55 | -------------------------------------------------------------------------------- /src/test/kotlin/functional/EnteringBuildingTest.kt: -------------------------------------------------------------------------------- 1 | package functional 2 | 3 | import com.natpryce.hamkrest.assertion.assertThat 4 | import com.natpryce.hamkrest.equalTo 5 | import env.TestEnvironment 6 | import env.enterBuilding 7 | import env.oauthserver.ACCESS_TOKEN_PREFIX 8 | import org.http4k.core.Status.Companion.ACCEPTED 9 | import org.http4k.core.Status.Companion.BAD_REQUEST 10 | import org.http4k.core.Status.Companion.CONFLICT 11 | import org.http4k.core.Status.Companion.NOT_FOUND 12 | import org.http4k.core.Status.Companion.TEMPORARY_REDIRECT 13 | import org.http4k.hamkrest.hasStatus 14 | import org.http4k.security.AccessToken 15 | import org.junit.jupiter.api.Test 16 | import verysecuresystems.EmailAddress 17 | import verysecuresystems.Id 18 | import verysecuresystems.User 19 | import verysecuresystems.UserEntry 20 | import verysecuresystems.Username 21 | import java.util.UUID 22 | 23 | class EnteringBuildingTest { 24 | 25 | private val env = TestEnvironment() 26 | 27 | @Test 28 | fun `unknown user is not allowed into building`() { 29 | assertThat(env.enterBuilding("Rita", AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID())), hasStatus(NOT_FOUND)) 30 | } 31 | 32 | @Test 33 | fun `rejects missing username in entry endpoint`() { 34 | assertThat(env.enterBuilding(null, AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID())), hasStatus(BAD_REQUEST)) 35 | } 36 | 37 | @Test 38 | fun `entry endpoint is protected with oauth token`() { 39 | assertThat(env.enterBuilding("Bob", null), hasStatus(TEMPORARY_REDIRECT)) 40 | } 41 | 42 | @Test 43 | fun `allows known user in and logs entry`() { 44 | env.userDirectory.contains(User(Id(1), Username("Bob"), EmailAddress("bob@bob.com"))) 45 | assertThat(env.enterBuilding("Bob", AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID())), hasStatus(ACCEPTED)) 46 | assertThat(env.entryLogger.entries, equalTo(listOf(UserEntry("Bob", true, env.clock.millis())))) 47 | } 48 | 49 | @Test 50 | fun `does not allow double entry`() { 51 | env.userDirectory.contains(User(Id(1), Username("Bob"), EmailAddress("bob@bob.com"))) 52 | val accessToken = AccessToken(ACCESS_TOKEN_PREFIX + UUID.randomUUID()) 53 | 54 | assertThat(env.enterBuilding("Bob", accessToken), hasStatus(ACCEPTED)) 55 | assertThat(env.enterBuilding("Bob", accessToken), hasStatus(CONFLICT)) 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/external/UserDirectory.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.external 2 | 3 | import org.http4k.cloudnative.RemoteRequestFailed 4 | import org.http4k.core.Body 5 | import org.http4k.core.ContentType.Companion.APPLICATION_FORM_URLENCODED 6 | import org.http4k.core.HttpHandler 7 | import org.http4k.core.Method.DELETE 8 | import org.http4k.core.Method.GET 9 | import org.http4k.core.Method.POST 10 | import org.http4k.core.Request 11 | import org.http4k.core.Status.Companion.ACCEPTED 12 | import org.http4k.core.Status.Companion.NOT_FOUND 13 | import org.http4k.core.Status.Companion.OK 14 | import org.http4k.core.body.form 15 | import org.http4k.core.then 16 | import org.http4k.core.with 17 | import org.http4k.filter.ClientFilters 18 | import org.http4k.filter.HandleRemoteRequestFailed 19 | import org.http4k.format.Jackson.auto 20 | import org.http4k.lens.Header.CONTENT_TYPE 21 | import verysecuresystems.EmailAddress 22 | import verysecuresystems.Id 23 | import verysecuresystems.User 24 | import verysecuresystems.Username 25 | 26 | /** 27 | * Business abstraction for the external UserDirectory service. Uses JSON-automarshalling via 28 | * Jackson to convert objects to Kotlin data-class instances 29 | */ 30 | class UserDirectory(http: HttpHandler) { 31 | 32 | // this filter will handle and rethrow non-successful HTTP responses 33 | private val http = ClientFilters.HandleRemoteRequestFailed({ status.successful || status == NOT_FOUND }).then(http) 34 | 35 | private val users = Body.auto>().toLens() 36 | private val user = Body.auto().toLens() 37 | 38 | fun create(name: Username, inEmail: EmailAddress): User = 39 | user( 40 | http(Request(POST, "/user") 41 | .with(CONTENT_TYPE of APPLICATION_FORM_URLENCODED) 42 | .form("email", inEmail.value) 43 | .form("username", name.value)) 44 | ) 45 | 46 | fun delete(idToDelete: Id) = 47 | with(http(Request(DELETE, "/user/${idToDelete.value}"))) { 48 | if (status != ACCEPTED) throw RemoteRequestFailed(status, "user directory") 49 | } 50 | 51 | fun list(): List = users(http(Request(GET, "/user"))) 52 | 53 | fun lookup(username: Username): User? = 54 | with(http(Request(GET, "/user/${username.value}"))) { 55 | when (status) { 56 | NOT_FOUND -> null 57 | OK -> user(this) 58 | else -> throw RemoteRequestFailed(status, "user directory") 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /src/main/resources/public/openapi/oauth2-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 68 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/SecuritySystem.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems 2 | 3 | import org.http4k.core.HttpHandler 4 | import org.http4k.core.Uri 5 | import org.http4k.core.then 6 | import org.http4k.events.EventFilters.AddTimestamp 7 | import org.http4k.events.EventFilters.AddZipkinTraces 8 | import org.http4k.events.Events 9 | import org.http4k.events.then 10 | import org.http4k.filter.ClientFilters.RequestTracing 11 | import org.http4k.filter.ClientFilters.SetHostFrom 12 | import org.http4k.filter.HandleRemoteRequestFailed 13 | import org.http4k.filter.ServerFilters 14 | import org.http4k.routing.ResourceLoader.Companion.Classpath 15 | import org.http4k.routing.routes 16 | import org.http4k.routing.static 17 | import verysecuresystems.api.Api 18 | import verysecuresystems.diagnostic.Auditor 19 | import verysecuresystems.diagnostic.Diagnostic 20 | import verysecuresystems.external.EntryLogger 21 | import verysecuresystems.external.UserDirectory 22 | import verysecuresystems.oauth.SecurityServerOAuthProvider 23 | import verysecuresystems.web.Web 24 | import java.time.Clock 25 | 26 | /** 27 | * Sets up the business-level API for the application. Note that the generic clients on the constructor allow us to 28 | * inject non-HTTP versions of the downstream dependencies so we can run tests without starting up real HTTP servers. 29 | */ 30 | fun SecuritySystem(clock: Clock, 31 | events: Events, 32 | http: HttpHandler, 33 | oauthCallbackUri: Uri, 34 | oauthServerUri: Uri, 35 | userDirectoryUri: Uri, 36 | entryLoggerUri: Uri): HttpHandler { 37 | val timedEvents = AddZipkinTraces() 38 | .then(AddTimestamp(clock)) 39 | .then(events) 40 | 41 | val timedHttp = RequestTracing().then(Auditor.Outgoing(timedEvents)).then(http) 42 | 43 | val inhabitants = Inhabitants() 44 | val oAuthProvider = SecurityServerOAuthProvider(oauthCallbackUri, oauthServerUri, SetHostFrom(oauthServerUri).then(timedHttp), clock) 45 | 46 | val userDirectory = UserDirectory(SetHostFrom(userDirectoryUri).then(timedHttp)) 47 | 48 | val entryLogger = EntryLogger(SetHostFrom(entryLoggerUri).then(timedHttp), clock) 49 | 50 | // we compose the various route blocks together here 51 | val app = routes( 52 | Api(userDirectory, entryLogger, inhabitants, oAuthProvider), 53 | Diagnostic(clock), 54 | Web(clock, oAuthProvider, userDirectory), 55 | static(Classpath("public")) 56 | ) 57 | 58 | // Create the application "stack", including inbound auditing 59 | return ServerFilters.CatchAll() 60 | .then(ServerFilters.RequestTracing()) 61 | .then(Auditor.Incoming(timedEvents)) 62 | .then(ServerFilters.HandleRemoteRequestFailed()) 63 | .then(app) 64 | } 65 | 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http4k-by-example application 2 | 3 |     4 | [![codecov](https://codecov.io/gh/http4k/http4k-by-example/branch/master/graph/badge.svg)](https://codecov.io/gh/http4k/http4k-by-example) 5 | 6 | #### about 7 | Complete TDD'd example [http4k](http://http4k.org) application showcasing a lot of the http4k features for building and testing apps. 8 | 9 | ##### Features: 10 | - Composable routing in both standard and contract (OpenAPI) forms with automatic parameter marshalling and unmarshalling (Headers/Query/Path/Body/Forms) 11 | - HTTP response building, including sample JSON library support (Jackson) and auto-data class instance marshalling 12 | - OpenAPI v3 documentation and JSON schema generation from example model objects and OAuth-based security 13 | - Automatic invalid request handling 14 | - Endpoint security via an OAuth (including simple OAuth Server implementation) 15 | - Templating system (Handlebars) 16 | - Typesafe Form handling with validation and error feedback 17 | - Configured via typesafe 12-factor configuration 18 | - Serving of static resources 19 | 20 | ##### Testing features: 21 | - Testing applications completely in-memory for ultra fast test suites 22 | - Approval-based testing for testing JSON and HTML responses 23 | - Hamkrest matchers for easy assertions on http4k objects 24 | - Reusable Fake HTTP dependencies, with behaviour proven by Consumer Driven Contracts 25 | - WebDriver usage for browser-based testing 26 | - Simulating failures with the http4k ChaosEngine 27 | 28 | It has been developed in a London-TDD style with outside-in acceptance testing and CDCs for outside dependencies, 29 | to give a complete overview of how the app would look when finished. 30 | 31 | #### running this demo app 32 | 1. Clone this repo 33 | 2. Run `RunnableEnvironment` from an IDE. This will start the application on port 9000, which has been configured to use a fake versions of the remote dependencies (on ports 10000, 11000 and 12000) 34 | 3. Just point your browser at [http://localhost:9000/](http://localhost:9000/) 35 | 4. OAuth login details are `user:password` 36 | 37 |
38 |
39 | 40 | # building security system 41 | 42 | #### requirements 43 | This example models a simple building security system accessible over HTTP. Requirements are: 44 | 45 | #### Functional: 46 | 1. Users can ask to be let into and out of the building. 47 | 1. Usernames are checked for validity against a remote HTTP UserDirectory system. 48 | 1. Successful entries and exits are logged in a remote HTTP EntryLogger system. 49 | 1. Ability to check on the current inhabitants of a building. 50 | 1. Users are tracking in a binary state - inside or not (outside). Only people outside the building can enter, and vice versa. 51 | 1. Custom UI (OAuth protected) to add users. 52 | 53 | #### Operational: 54 | 1. API documentation should be available with security enforced via OAuth login. 55 | 1. All API HTTP endpoints are protected with bearer token to only allow authorised access. 56 | 1. Logging of every successful requests should be made. 57 | 1. Support distributed tracing via Zipkin headers 58 | -------------------------------------------------------------------------------- /src/test/kotlin/env/oauthserver/SimpleOAuthServer.kt: -------------------------------------------------------------------------------- 1 | package env.oauthserver 2 | 3 | import env.oauthserver.SimpleOAuthServer.Form.formLens 4 | import env.oauthserver.SimpleOAuthServer.Form.password 5 | import env.oauthserver.SimpleOAuthServer.Form.username 6 | import org.http4k.core.Body 7 | import org.http4k.core.Credentials 8 | import org.http4k.core.HttpHandler 9 | import org.http4k.core.Method 10 | import org.http4k.core.Method.GET 11 | import org.http4k.core.Method.POST 12 | import org.http4k.core.Response 13 | import org.http4k.core.Status.Companion.OK 14 | import org.http4k.core.Status.Companion.SEE_OTHER 15 | import org.http4k.core.then 16 | import org.http4k.core.with 17 | import org.http4k.filter.AllowAll 18 | import org.http4k.filter.CorsPolicy 19 | import org.http4k.filter.OriginPolicy 20 | import org.http4k.filter.ServerFilters.Cors 21 | import org.http4k.format.Jackson 22 | import org.http4k.lens.FormField 23 | import org.http4k.lens.Header 24 | import org.http4k.lens.Validator.Strict 25 | import org.http4k.lens.webForm 26 | import org.http4k.routing.bind 27 | import org.http4k.routing.routes 28 | import org.http4k.security.oauth.server.InsecureCookieBasedAuthRequestTracking 29 | import org.http4k.security.oauth.server.OAuthServer 30 | import java.time.Clock 31 | 32 | object SimpleOAuthServer { 33 | operator fun invoke(credentials: Credentials, vararg oAuthClientData: OAuthClientData): HttpHandler { 34 | val clock = Clock.systemUTC() 35 | val server = OAuthServer( 36 | "/oauth2/token", 37 | InMemoryAuthRequestTracking(), 38 | SimpleClientValidator(*oAuthClientData), 39 | InMemoryAuthorizationCodes(clock), 40 | SimpleAccessTokens(), 41 | clock, 42 | Jackson 43 | ) 44 | 45 | val userAuth = UserAuthentication(credentials) 46 | 47 | // this CORS filter is here to allow interactions from the OpenAPI UI (running in a browser) 48 | return Cors(CorsPolicy(OriginPolicy.AllowAll(), listOf("*"), Method.values().toList())) 49 | .then( 50 | routes( 51 | server.tokenRoute, 52 | "/" bind routes( 53 | GET to server.authenticationStart.then { Response(OK).body(LOGIN_PAGE) }, 54 | POST to { request -> 55 | val form = formLens(request) 56 | if (userAuth.authenticate(Credentials(username(form), password(form)))) { 57 | server.authenticationComplete(request) 58 | } else Response(SEE_OTHER).with(Header.LOCATION of request.uri) 59 | } 60 | ) 61 | ) 62 | ) 63 | } 64 | 65 | private object Form { 66 | val username = FormField.required("username") 67 | val password = FormField.required("password") 68 | val formLens = Body.webForm(Strict, username, password).toLens() 69 | } 70 | } 71 | 72 | private const val LOGIN_PAGE = """ 73 | 74 |
75 |
76 |
77 | 78 |
79 | 80 | """ 81 | 82 | -------------------------------------------------------------------------------- /src/test/kotlin/env/userdirectory/FakeUserDirectory.kt: -------------------------------------------------------------------------------- 1 | package env.userdirectory 2 | 3 | import org.http4k.chaos.ChaosBehaviours.ReturnStatus 4 | import org.http4k.chaos.ChaosEngine 5 | import org.http4k.chaos.withChaosApi 6 | import org.http4k.core.Body 7 | import org.http4k.core.HttpHandler 8 | import org.http4k.core.Method.DELETE 9 | import org.http4k.core.Method.GET 10 | import org.http4k.core.Method.POST 11 | import org.http4k.core.Request 12 | import org.http4k.core.Response 13 | import org.http4k.core.Status.Companion.ACCEPTED 14 | import org.http4k.core.Status.Companion.CREATED 15 | import org.http4k.core.Status.Companion.I_M_A_TEAPOT 16 | import org.http4k.core.Status.Companion.NOT_FOUND 17 | import org.http4k.core.Status.Companion.OK 18 | import org.http4k.core.with 19 | import org.http4k.format.Jackson.auto 20 | import org.http4k.lens.FormField 21 | import org.http4k.lens.Path 22 | import org.http4k.lens.Validator.Strict 23 | import org.http4k.lens.int 24 | import org.http4k.lens.webForm 25 | import org.http4k.routing.bind 26 | import org.http4k.routing.routes 27 | import verysecuresystems.EmailAddress 28 | import verysecuresystems.Id 29 | import verysecuresystems.User 30 | import verysecuresystems.Username 31 | import java.util.Random 32 | 33 | class FakeUserDirectory(private val idGen: () -> Int = Random()::nextInt) : HttpHandler { 34 | 35 | private val engine = ChaosEngine() 36 | 37 | private val users = mutableMapOf() 38 | 39 | fun blowsUp() { 40 | engine.enable(ReturnStatus(I_M_A_TEAPOT)) 41 | } 42 | 43 | fun contains(newUser: User) = users.put(newUser.id, newUser) 44 | 45 | private fun create(): HttpHandler { 46 | val email = FormField.map(::EmailAddress, EmailAddress::value).required("email") 47 | val username = FormField.map(::Username, Username::value).required("username") 48 | val form = Body.webForm(Strict, email, username).toLens() 49 | val user = Body.auto().toLens() 50 | 51 | return { 52 | val data = form(it) 53 | val newUser = User(Id(idGen()), username(data), email(data)) 54 | users[newUser.id] = newUser 55 | Response(CREATED).with(user of newUser) 56 | } 57 | } 58 | 59 | private fun delete(): HttpHandler { 60 | val id = Path.int().map(::Id, Id::value).of("id") 61 | return { 62 | users.remove(id(it))?.let { Response(ACCEPTED) } ?: Response(NOT_FOUND) 63 | } 64 | } 65 | 66 | private fun list(): HttpHandler = { 67 | val userList = Body.auto>().toLens() 68 | Response(OK).with(userList of users.values.toList()) 69 | } 70 | 71 | private fun lookup(): HttpHandler { 72 | val username = Path.map(::Username, Username::value).of("username") 73 | val user = Body.auto().toLens() 74 | 75 | return { 76 | val searchFor = username(it) 77 | users.values.firstOrNull { it.name == searchFor } 78 | ?.let { Response(OK).with(user of it) } 79 | ?: Response(NOT_FOUND) 80 | } 81 | } 82 | 83 | private val app = routes( 84 | "/user" bind GET to list(), 85 | "/user" bind POST to create(), 86 | "/user/{id}" bind DELETE to delete(), 87 | "/user/{username}" bind GET to lookup() 88 | ).withChaosApi(engine) 89 | 90 | override fun invoke(p1: Request) = app(p1) 91 | } -------------------------------------------------------------------------------- /src/test/kotlin/env/TestEnvironment.kt: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import env.entrylogger.FakeEntryLogger 4 | import env.oauthserver.OAuthClientData 5 | import env.oauthserver.SimpleOAuthServer 6 | import env.userdirectory.FakeUserDirectory 7 | import org.http4k.core.Credentials 8 | import org.http4k.core.HttpHandler 9 | import org.http4k.core.Method.GET 10 | import org.http4k.core.Method.POST 11 | import org.http4k.core.Request 12 | import org.http4k.core.Response 13 | import org.http4k.core.Uri 14 | import org.http4k.core.with 15 | import org.http4k.lens.Header 16 | import org.http4k.lens.Query 17 | import org.http4k.routing.reverseProxy 18 | import org.http4k.security.AccessToken 19 | import org.http4k.testing.RecordingEvents 20 | import org.http4k.webdriver.Http4kWebDriver 21 | import org.openqa.selenium.By 22 | import verysecuresystems.SecuritySystem 23 | import java.time.Clock.fixed 24 | import java.time.Instant.ofEpochSecond 25 | import java.time.LocalDate 26 | import java.time.LocalTime.MIDNIGHT 27 | import java.time.ZoneId 28 | import java.time.ZoneOffset.UTC 29 | import java.util.Random 30 | 31 | class TestEnvironment { 32 | val clock = fixed(ofEpochSecond(LocalDate.of(3000, 1, 1).toEpochSecond(MIDNIGHT, UTC)), ZoneId.of("UTC"))!! 33 | 34 | val userDirectory = FakeUserDirectory { Random(1).nextInt() } 35 | val entryLogger = FakeEntryLogger() 36 | 37 | private val events = RecordingEvents() 38 | 39 | private val oAuthClientData = OAuthClientData( 40 | Credentials("securityServer", "securityServerSecret"), 41 | Uri.of("http://security/api/oauth/callback") 42 | ) 43 | private val credentials = Credentials("user", "password") 44 | 45 | private val oauthServer = SimpleOAuthServer(credentials, oAuthClientData) 46 | 47 | private val securityServer = 48 | SecuritySystem( 49 | clock, 50 | events, 51 | reverseProxy( 52 | "oauth" to oauthServer, 53 | "userDirectory" to userDirectory, 54 | "entryLogger" to entryLogger 55 | ), 56 | Uri.of("http://security"), 57 | Uri.of("http://oauth"), 58 | Uri.of("http://userDirectory"), 59 | Uri.of("http://entryLogger") 60 | ) 61 | 62 | // this HttpHandler handles switching of hosts from the security server and the oauth server 63 | // (due to redirects happening during the OAuth flow) 64 | val http: HttpHandler = { 65 | if (it.uri.authority == "oauth") oauthServer(it) else securityServer(it) 66 | } 67 | 68 | val browser = Http4kWebDriver(http) 69 | } 70 | 71 | private val username = Query.optional("username") 72 | private val authorization = Header.map({ AccessToken(it.removePrefix("Bearer ")) }, { "Bearer ${it.value}" } 73 | ).optional("Authorization") 74 | 75 | fun TestEnvironment.enterBuilding(user: String?, token: AccessToken?): Response = 76 | http(Request(POST, "/api/knock").with(username of user, authorization of token)) 77 | 78 | fun TestEnvironment.exitBuilding(user: String?, token: AccessToken?): Response = 79 | http(Request(POST, "/api/bye").with(username of user, authorization of token)) 80 | 81 | fun TestEnvironment.checkInhabitants(token: AccessToken?): Response = 82 | http(Request(GET, "/api/whoIsThere").with(authorization of token)) 83 | 84 | fun Http4kWebDriver.logIn() = apply { 85 | get(Uri.of("/users")) 86 | findElement(By.id("loginForm"))?.apply { submit() } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/kotlin/verysecuresystems/oauth/InMemoryOAuthPersistence.kt: -------------------------------------------------------------------------------- 1 | package verysecuresystems.oauth 2 | 3 | import org.http4k.core.Request 4 | import org.http4k.core.Response 5 | import org.http4k.core.Status.Companion.FORBIDDEN 6 | import org.http4k.core.Uri 7 | import org.http4k.core.cookie.Cookie 8 | import org.http4k.core.cookie.cookie 9 | import org.http4k.core.cookie.invalidateCookie 10 | import org.http4k.security.AccessToken 11 | import org.http4k.security.CrossSiteRequestForgeryToken 12 | import org.http4k.security.Nonce 13 | import org.http4k.security.OAuthCallbackError 14 | import org.http4k.security.OAuthPersistence 15 | import org.http4k.security.PkceChallengeAndVerifier 16 | import org.http4k.security.openid.IdToken 17 | import java.time.Clock 18 | import java.time.Duration 19 | import java.util.UUID 20 | 21 | /** 22 | * This persistence handles both Bearer-token (API) and cookie-swapped access token (standard OAuth-web) flows. 23 | */ 24 | class InMemoryOAuthPersistence(private val clock: Clock, private val tokenChecker: TokenChecker) : OAuthPersistence { 25 | private val csrfName = "securityServerCsrf" 26 | private val originalUriName = "securityServerUri" 27 | private val clientAuthCookie = "securityServerAuth" 28 | private val cookieSwappableTokens = mutableMapOf() 29 | 30 | override fun retrieveCsrf(request: Request) = request.cookie(csrfName)?.value?.let(::CrossSiteRequestForgeryToken) 31 | 32 | override fun retrieveNonce(request: Request): Nonce? = null 33 | 34 | override fun retrieveOriginalUri(request: Request): Uri? = request.cookie(originalUriName)?.value?.let(Uri::of) 35 | 36 | override fun retrievePkce(request: Request) = null 37 | 38 | override fun retrieveToken(request: Request) = (tryBearerToken(request) 39 | ?: tryCookieToken(request)) 40 | ?.takeIf(tokenChecker::check) 41 | 42 | override fun assignCsrf(redirect: Response, csrf: CrossSiteRequestForgeryToken) = redirect.cookie(expiring(csrfName, csrf.value)) 43 | 44 | override fun assignNonce(redirect: Response, nonce: Nonce): Response = redirect 45 | 46 | override fun assignOriginalUri(redirect: Response, originalUri: Uri): Response = redirect.cookie(expiring(originalUriName, originalUri.toString())) 47 | 48 | override fun assignPkce(redirect: Response, pkce: PkceChallengeAndVerifier) = redirect 49 | 50 | override fun assignToken(request: Request, redirect: Response, accessToken: AccessToken, idToken: IdToken?) = 51 | UUID.randomUUID().let { 52 | cookieSwappableTokens[it.toString()] = accessToken 53 | redirect 54 | .cookie(expiring(clientAuthCookie, it.toString())) 55 | .invalidateCookie(csrfName) 56 | .invalidateCookie(originalUriName) 57 | } 58 | 59 | override fun authFailureResponse(reason: OAuthCallbackError) = Response(FORBIDDEN) 60 | .invalidateCookie(csrfName) 61 | .invalidateCookie(originalUriName) 62 | .invalidateCookie(clientAuthCookie) 63 | 64 | private fun tryCookieToken(request: Request) = 65 | request.cookie(clientAuthCookie)?.value?.let { cookieSwappableTokens[it] } 66 | 67 | private fun tryBearerToken(request: Request) = request.header("Authorization") 68 | ?.removePrefix("Bearer ") 69 | ?.let { AccessToken(it) } 70 | 71 | private fun expiring(name: String, value: String) = Cookie(name, value, 72 | path = "/", 73 | expires = clock.instant().plus(Duration.ofDays(1))) 74 | } 75 | 76 | 77 | fun main() { 78 | 79 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /src/test/resources/nonfunctional/OpenApiContractTest.provides API documentation in open api format.approved: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "Security Server API", 4 | "version": "v1.0", 5 | "description": "This API is secured by an OAuth auth code. Simply click 'Authorize' to start the flow." 6 | }, 7 | "tags": [ 8 | ], 9 | "servers": [ 10 | { 11 | "url": "/" 12 | } 13 | ], 14 | "paths": { 15 | "/api/bye": { 16 | "post": { 17 | "summary": "User exits the building", 18 | "tags": [ 19 | "/api" 20 | ], 21 | "parameters": [ 22 | { 23 | "schema": { 24 | "type": "string" 25 | }, 26 | "in": "query", 27 | "name": "username", 28 | "required": true 29 | } 30 | ], 31 | "responses": { 32 | "202": { 33 | "description": "Accepted", 34 | "content": { 35 | "application/json": { 36 | "example": { 37 | "message": "processing" 38 | }, 39 | "schema": { 40 | "$ref": "#/components/schemas/Message" 41 | } 42 | } 43 | } 44 | }, 45 | "404": { 46 | "description": "Not Found", 47 | "content": { 48 | "application/json": { 49 | "example": { 50 | "message": "User is not inside building" 51 | }, 52 | "schema": { 53 | "$ref": "#/components/schemas/Message" 54 | } 55 | } 56 | } 57 | }, 58 | "401": { 59 | "description": "Incorrect key", 60 | "content": { 61 | } 62 | } 63 | }, 64 | "security": [ 65 | { 66 | "oauthSecurityAuthCode": [ 67 | ] 68 | } 69 | ], 70 | "operationId": "postApiBye", 71 | "deprecated": false 72 | } 73 | }, 74 | "/api/knock": { 75 | "post": { 76 | "summary": "User enters the building", 77 | "tags": [ 78 | "/api" 79 | ], 80 | "parameters": [ 81 | { 82 | "schema": { 83 | "type": "string" 84 | }, 85 | "in": "query", 86 | "name": "username", 87 | "required": true 88 | } 89 | ], 90 | "responses": { 91 | "202": { 92 | "description": "Accepted", 93 | "content": { 94 | "application/json": { 95 | "example": { 96 | "message": "Access granted" 97 | }, 98 | "schema": { 99 | "$ref": "#/components/schemas/Message" 100 | } 101 | } 102 | } 103 | }, 104 | "404": { 105 | "description": "Not Found", 106 | "content": { 107 | "application/json": { 108 | "example": { 109 | "message": "Unknown user" 110 | }, 111 | "schema": { 112 | "$ref": "#/components/schemas/Message" 113 | } 114 | } 115 | } 116 | }, 117 | "409": { 118 | "description": "Conflict", 119 | "content": { 120 | "application/json": { 121 | "example": { 122 | "message": "User is already inside building" 123 | }, 124 | "schema": { 125 | "$ref": "#/components/schemas/Message" 126 | } 127 | } 128 | } 129 | }, 130 | "401": { 131 | "description": "Incorrect key", 132 | "content": { 133 | } 134 | } 135 | }, 136 | "security": [ 137 | { 138 | "oauthSecurityAuthCode": [ 139 | ] 140 | } 141 | ], 142 | "operationId": "postApiKnock", 143 | "deprecated": false 144 | } 145 | }, 146 | "/api/whoIsThere": { 147 | "get": { 148 | "summary": "List current users in the building", 149 | "tags": [ 150 | "/api" 151 | ], 152 | "parameters": [ 153 | ], 154 | "responses": { 155 | "200": { 156 | "description": "Inhabitant list", 157 | "content": { 158 | "application/json": { 159 | "example": [ 160 | { 161 | "id": { 162 | "value": 1 163 | }, 164 | "name": { 165 | "value": "A user" 166 | }, 167 | "email": { 168 | "value": "user@bob.com" 169 | } 170 | } 171 | ], 172 | "schema": { 173 | "items": { 174 | "$ref": "#/components/schemas/User" 175 | }, 176 | "example": [ 177 | { 178 | "id": { 179 | "value": 1 180 | }, 181 | "name": { 182 | "value": "A user" 183 | }, 184 | "email": { 185 | "value": "user@bob.com" 186 | } 187 | } 188 | ], 189 | "type": "array", 190 | "nullable": false 191 | } 192 | } 193 | } 194 | } 195 | }, 196 | "security": [ 197 | { 198 | "oauthSecurityAuthCode": [ 199 | ] 200 | } 201 | ], 202 | "operationId": "getApiWhoIsThere", 203 | "deprecated": false 204 | } 205 | } 206 | }, 207 | "components": { 208 | "schemas": { 209 | "Message": { 210 | "properties": { 211 | "message": { 212 | "example": "User is not inside building", 213 | "type": "string", 214 | "nullable": false 215 | } 216 | }, 217 | "example": { 218 | "message": "User is not inside building" 219 | }, 220 | "type": "object", 221 | "required": [ 222 | "message" 223 | ] 224 | }, 225 | "User": { 226 | "properties": { 227 | "id": { 228 | "$ref": "#/components/schemas/Id" 229 | }, 230 | "name": { 231 | "$ref": "#/components/schemas/Username" 232 | }, 233 | "email": { 234 | "$ref": "#/components/schemas/EmailAddress" 235 | } 236 | }, 237 | "example": { 238 | "id": { 239 | "value": 1 240 | }, 241 | "name": { 242 | "value": "A user" 243 | }, 244 | "email": { 245 | "value": "user@bob.com" 246 | } 247 | }, 248 | "type": "object", 249 | "required": [ 250 | "email", 251 | "id", 252 | "name" 253 | ] 254 | }, 255 | "Id": { 256 | "properties": { 257 | "value": { 258 | "example": 1, 259 | "format": "int32", 260 | "type": "integer", 261 | "nullable": false 262 | } 263 | }, 264 | "example": { 265 | "value": 1 266 | }, 267 | "type": "object", 268 | "required": [ 269 | "value" 270 | ] 271 | }, 272 | "Username": { 273 | "properties": { 274 | "value": { 275 | "example": "A user", 276 | "type": "string", 277 | "nullable": false 278 | } 279 | }, 280 | "example": { 281 | "value": "A user" 282 | }, 283 | "type": "object", 284 | "required": [ 285 | "value" 286 | ] 287 | }, 288 | "EmailAddress": { 289 | "properties": { 290 | "value": { 291 | "example": "user@bob.com", 292 | "type": "string", 293 | "nullable": false 294 | } 295 | }, 296 | "example": { 297 | "value": "user@bob.com" 298 | }, 299 | "type": "object", 300 | "required": [ 301 | "value" 302 | ] 303 | } 304 | }, 305 | "securitySchemes": { 306 | "oauthSecurityAuthCode": { 307 | "type": "oauth2", 308 | "flows": { 309 | "authorizationCode": { 310 | "authorizationUrl": "http://oauth/", 311 | "tokenUrl": "http://oauth/oauth2/token", 312 | "scopes": { 313 | } 314 | } 315 | } 316 | } 317 | } 318 | }, 319 | "openapi": "3.0.0" 320 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------