diff --git a/README.md b/README.md index c398f68..6f19839 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ See either this repository [releases page](https://github.com/massenz/jwt-opa/re ```groovy ext { - jwtOpaVersion = '0.8.0' + jwtOpaVersion = '0.9.0' } ``` @@ -163,10 +163,78 @@ This will eventually send a `TokenBasedAuthorizationRequestBody` (encoded as JSO Depending on what the `allow` rule maps to, this will eventually grant/deny access to the requested endpoint (given the HTTP Method and, optionally, the request's body content). -See [OPA Policies](#opa-policies) for what this maps to, and the [OPA Documentation](https://www.openpolicyagent.org/docs/latest/policy-reference) for more details on Rego and the server REST API. +There is a relationship between the `policy` endpoint and the Rego `package` in your policy: they **must** match, with dots in the package replaced by slashes in the policy path: +``` +# Rego: +package com.alertavert.policies + +grant { + # the policy +} + +# application.yaml +opa: + policy: com/alertavert/policies + rule: grant +``` + +See [OPA Policies](#opa-policies) for more details, and the [OPA Documentation](https://www.openpolicyagent.org/docs/latest/policy-reference) for more on Rego and the OPA server API. + + +# Signing Secrets + +## Overview + +In order to ensure validity of its contents, a JWT needs to be cryptographically signed and the signature added to its body; see [the JWT Handbook](https://auth0.com/resources/ebooks/jwt-handbook) for more details. + +`jwt-opa` offers currently two signature methods for JWTs: + +* a passphrase (secret), using symmetric encryption which needs to be used for both signing and authenticating the JWT; and + +* asymmetric Private/Public keypair (using Elliptic Cryptography) where the private key is used to sign and the public key can be used to validate the JWT. + +The advantage of the latter is that the Public key can be distributed, and any service (including others completely unrelated to `jwt-opa`) can validate the API Token. + +This is being used, for example, by [Copilot IQ](https://copilotiq.com) to use `jwt-opa` (integrated within its Spring Boot API server) to provide API Token for its Lambda Go functions, where they ask `jwt-opa` to generate trusted API Token, but then authentication can be carried out indipedently by the Lambdas, without ever needing to incur the cost of an additional call to the API server. + +This also points to the advantage of using OPA as an authorization service, which can serve several disparate other services, completely abstracting away the authorization logic. + +## Secrets Configuration + +Key configuration is done via Spring Boot externalized configuration (e.g., in [`application.yaml`](https://github.com/massenz/jwt-opa/blob/main/webapp-example/src/main/resources/application.yaml#L81-L104)) via the `keys` object; this in turn has the following fields: + +```yaml +keys: + algorithm: EC + location: keypair + name: /var/local/keys/ec-key +``` + +Possible values for `algorithm` are: + +- `PASSPHRASE`: plaintext secret +- `EC`: Elliptic Curve cryptography key pair + +Depending on the value of `location` the `name` property has a different meaning: + +- only available for `PASSPHRASE` + - `env`
env var name which contains the signing secret + - `file`
the path to file whose contents are the plaintext secret this is **NOT** secure and should only be used for dev/testing + + +- only available for `EC` + - `keypair`
the relative or absolute path to the keypair, without extension, to which `.pem` and `.pub` will be added + + +- either of `EC` or `PASSPHRASE`: + - `awssecret`
name of AWS SecretsManager secret + - `vaultpath`
path in HashiCorp Vault (**not implemented yet**) + +In the above, file paths can be absolute or relative (in production use, we recommend full absolute paths to avoid hard-to-debug issues - at any rate, the error message should be sufficient to locate the source of the issue). + +When using `aswsecret`, a `PASSPHRASE` is simply read from SecretsManager/Vault as plaintext, while for an `EC` `KeyPair` it is stored as a JSON-formatted secret, with two keys: `priv` and `pub` (see [AWS SecretsManager support](#aws-secretsmanager-support)). -# Running ## Generating a `KeyPair` @@ -176,32 +244,120 @@ Use the `keygen.sh` script, specifying the name of the keys and, optionally, a f See [this](https://github.com/auth0/java-jwt/issues/270) for more details. -Briefly, an "elliptic cryptography" key pair can be generated with: +Make sure the keys are in a **private** folder (not under source control) and then point the relevant application configuration (`application.yaml`) to them: + +```yaml +keys: + algorithm: ec + location: keypair + name: "private/ec-key" +``` + +You can use either an absolute path, or the relative path to the current directory from where you are launching the Web server, and make sure to includ the keys' filename, but **not** the extension(s) (`.pem` and `.pub`) as the `KeypairFileReader` will add them automatically. + +## AWS SecretsManager support + +**This is the recommended secure way to store and access signing secrets** + +We support storing signing secrets (both plaintext passphrase or a private/public key pair) in [AWS SecretsManager](https://aws.amazon.com/secrets-manager) by simply configuring access to AWS: + +```yaml +aws: + region: us-west-2 + profile: my-profile +``` + +the `profile` must match one of those configured in the `~/.aws/credentials` file: + +``` +# my-profile +[my-profile] +aws_access_key_id = AJIA2....XT +aws_secret_access_key = 22Y8...YM +``` + +we also support direct acces to SM via IAM Roles when `jwt-opa` is embedded in a service running on AWS (e.g., as a pod in [Amazon Kubernetes](https://aws.amazon.com/eks/)) via a Token file whose name is stored in the `AWS_TOKEN_FILE` env var (see the documentation for AWS SDK's `WebIdentityTokenFileCredentialsProvider`) -- in this case you should **not** specify a `aws.profile` or the client will fail to authenticate. + +We also support connecting to a running instance of [LocalStack](https://localstack.io) via the `endpoint_url` configuration: + +``` +aws: + region: us-west-2 + profile: default + endpoint: http://localhost:4566 +``` -1. generate the EC param +Run LocalStack via docker with something like (this is a `compose.yaml` fragment, YMMV): - openssl ecparam -name prime256v1 -genkey -noout -out ec-key.pem +``` + 19 │ localstack: + 20 │ container_name: "awslocal" + 21 │ image: "localstack/localstack:1.3" + 22 │ hostname: awslocal + 23 │ environment: + 24 │ - AWS_REGION=us-west-2 + 25 │ - EDGE_PORT=4566 + 26 │ - SERVICES=sqs + 27 │ ports: + 28 │ - '4566:4566' + 29 │ volumes: + 30 │ - "${TMPDIR:-/tmp}/localstack:/var/lib/localstack" + 31 │ - "/var/run/docker.sock:/var/run/docker.sock" + 32 │ networks: + 33 │ - sm-net +``` -2. generate EC private key +Prior to running the webapp, upload the secret with: - openssl pkcs8 -topk8 -inform pem -in ec-key.pem -outform pem \ - -nocrypt -out ec-key-1.pem +``` + export AWS_REGION=us-west-2 + export AWS_ENDPOINT=http://localhost:4566 + aws --endpoint-url $AWS_ENDPOINT secretsmanager create-secret --name demo-secret \ + --secret-string "astrong-secret-dce44st" +``` -3. generate EC public key +To upload a keypair to AWS SM, the easiest way is to use the `aws-upload-keys` script, after having set the `AWS_PROFILE` env var and generated the keys: - openssl ec -in ec-key-1.pem -pubout -out public.pem +``` +export AWS_PROFILE=my-profile +export AWS_REGION=us-east-1 +./keygen.sh dev-keys testdata +./aws-upload-keys.sh testdata/dev-keys dev-keypair +``` -Save both keys in a `private` folder (not under source control) and then point the relevant application configuration (`application.yaml`) to them: +these can then be made available to the application via the following `application.yaml` configuration: ```yaml -secrets: - keypair: - private: "private/ec-key-1.pem" - pub: "private/ec-key.pub" +aws: + region: us-east-1 + profile: my-profile + +keys: + algorithm: EC + location: awssecret + name: dev-keypair ``` -You can use either an absolute path, or the relative path to the current directory from where you are launching the Web server. +*Key Format*
+While not relevant for library users, the KeyPair is stored in SM as a JSON object, with two `pub` and `priv` fields, which are the contents of the keys (base-64 encoded binary) without delimiters: +``` +└─( aws --output json secretsmanager list-secrets \ + | jq -r ".SecretList[].Name" | grep dev + +└─( echo -e $(aws --output json secretsmanager get-secret-value \ + --secret-id dev-keypair | jq -r .SecretString) + +{ "priv": "AMB....Pi/88", "pub": "MF....v+A==" } +``` + + +## Hashicorp Vault support + +**This is not implemented yet**, see [Issue #49](https://github.com/massenz/jwt-opa/issues/49). + + +# Running the Server ## Supporting Services @@ -216,7 +372,25 @@ Use the following to run the servers locally: ./run-example.sh ``` -`TODO:` a full Kubernetes service/pod spec to run all services. +You can also optionally pass in a value for the Spring Boot profile to use (and relative configuration to use, if defined): + +``` +./run-example.sh debug,dev + +2023-01-07 15:07:37.015 INFO : Starting JwtDemoApplication using Java 17 on gondor with PID 363820 +2023-01-07 15:07:37.017 INFO : The following profiles are active: debug,dev +... +``` + +The service will continue running after you stop the server via Ctrl-C (as you may want to re-run it via `./gradlew bootRun`): to stop the `opa` and `mongo` containers too, simply use: + + docker compose down + +from the same directory as the `compose.yaml` is stored, or point to it via the `-f` option. + + +`TODO:` a Helm chart to run *all* services on a Kubernetes cluster. + ## Web Server (Demo app) @@ -230,18 +404,31 @@ In future releases of the `jwt-opa` library we may also provide "default" implem `TODO:` there are stil a few rough edges in the demo app and its APIs. + ### Trying out the demo -After starting the server (`./gradlew bootRun`), you will see in the log the generated password -for the `admin` user: +> **NOTE** +> +> As this is a toy demo, we happily store the password in a source-controlled configuration file: you should easily realize that **this is an extremely dumb thing to do**, please don't do it. + +The `admin` password is stored in `application.yaml`: - INFO Initializing DB with seed user (admin) - INFO Use the generated password: 342dfa7b-4 +``` +db: + server: localhost + port: 27017 + name: opa-demo-db + + # Obviously, well, DON'T DO THIS for a real server. + admin: + username: admin + password: 8535b9c4-a +``` **Note** -> The system user does not get re-created, if it already exists: if you lose the random password, you will need to manually delete it from Mongo directly: +> The system user does not get re-created, if it already exists: if you change (and then forget) the password, you will need to manually delete it from Mongo directly: ``` docker exec -it mongo mongo @@ -265,22 +452,48 @@ this will generate a new API Token, that can then be used in subsequent HTTP API http :8080/users Authorization:"Bearer ... JWT goes here ..." - # OPA Policies They are stored in `src/main/rego` and can be uploaded to the OPA policy server via a `curl POST` (see `REST API` in [Useful Links](useful-links#)); examples of policy evaulations are in `src/test/policies_tests` as JSON files; they can be executed against the policy server using the `/data` endpoint: - POST http://localhost:8181/v1/data/kapsules/valid_token + POST http://localhost:8181/v1/data/com/alertavert/userauth/allow { - "input": { - "user": "myuser", - "role": "USER", - "token": "eyJ0eXAi....iCzY" + "input" : { + "api_token" : "eyJ0eX****e9ZuZA", + "resource" : { + "method" : "GET", + "path" : "/users", + } } } +The actual format of the request POSTed to OPA can be seen in the Debug logs of the server: + +``` +2023-01-07 15:21:29.335 DEBUG : POST Authorization request: +{ + "input" : { + "api_token" : "eyJ0eX****e9ZuZA", + "resource" : { + "method" : "GET", + "path" : "/users", + "headers" : { + "User-Agent" : "PostmanRuntime/7.30.0", + "Host" : "localhost:8081", + "Accept-Encoding" : "gzip, deflate, br" + } + } + } +} +2023-01-07 15:21:29.458 DEBUG : OPA Server returned: {result=true} +2023-01-07 15:21:29.458 DEBUG : JWT Auth Web Filter :: GET /users +2023-01-07 15:21:29.458 DEBUG : Authenticating token eyJ0eX... +2023-01-07 15:21:29.462 DEBUG : API Token valid: sub = `admin`, authorities = [SYSTEM] +2023-01-07 15:21:29.462 DEBUG : Validated API Token for Principal: `admin` +2023-01-07 15:21:29.462 DEBUG : Auth success, principal = `JwtPrincipal(sub=admin)` +``` ### Useful links diff --git a/docker-compose.yml b/compose.yml similarity index 92% rename from docker-compose.yml rename to compose.yml index effc199..2c8ff96 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ version: '3.2' services: opa: - container_name: opa + container_name: "opa" hostname: opa image: openpolicyagent/opa:0.42.2 command: run --server --addr :8181 @@ -37,4 +37,4 @@ networks: backend: ipam: config: - - subnet: 172.1.2.0/24 + - subnet: 172.10.2.0/24 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..e411586 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/jwt-opa/build.gradle b/jwt-opa/build.gradle index 3372654..65e5a63 100644 --- a/jwt-opa/build.gradle +++ b/jwt-opa/build.gradle @@ -17,10 +17,10 @@ */ plugins { - id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'io.spring.dependency-management' version '1.1.3' id 'java' id 'jacoco' - id 'org.springframework.boot' version '2.5.7' + id 'org.springframework.boot' version '3.1.5' // To upload the Artifact to Maven Central // See: https://docs.gradle.org/current/userguide/publishing_maven.html @@ -38,7 +38,7 @@ ext { } group 'com.alertavert' -version '0.9.0' +version '0.10.0' // OpenJDK 17 LTS is the only Java version supported sourceCompatibility = JavaVersion.VERSION_17 @@ -78,7 +78,6 @@ dependencies { // See: https://stackoverflow.com/questions/29805622/could-not-find-or-load-main-class-org-gradle-wrapper-gradlewrappermain/31622432 implementation group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.2' - annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" annotationProcessor "org.projectlombok:lombok:${lombokVersion}" compileOnly "org.projectlombok:lombok:${lombokVersion}" @@ -86,16 +85,20 @@ dependencies { implementation 'org.bouncycastle:bcprov-jdk15on:1.70' implementation 'commons-codec:commons-codec:1.13' - testImplementation('org.springframework.boot:spring-boot-starter-test') { - exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' - } - + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-webflux' // AWS SDK for Secrets Manager, see: https://docs.aws.amazon.com/code-samples/latest/catalog/code-catalog-javav2-example_code-secretsmanager.html implementation "software.amazon.awssdk:secretsmanager:${awsSdkVersion}" + // For the @PostConstruct annotation + implementation 'javax.annotation:javax.annotation-api:1.3.2' + + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + testImplementation "com.jayway.jsonpath:json-path-assert:$jsonpathVersion" testImplementation "org.mockito:mockito-core:$mockitoVersion" @@ -104,7 +107,6 @@ dependencies { testImplementation "org.testcontainers:junit-jupiter:$tcVersion" testImplementation "org.testcontainers:localstack:$tcVersion" testImplementation group: 'com.amazonaws', name: 'aws-java-sdk-core', version: '1.12.326' - } jacocoTestCoverageVerification { diff --git a/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java b/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java index be0c405..b40e77c 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/jwt/ApiTokenAuthenticationFactory.java @@ -44,8 +44,11 @@ @Service @Slf4j public class ApiTokenAuthenticationFactory { - @Autowired - JwtTokenProvider provider; + private final JwtTokenProvider provider; + + public ApiTokenAuthenticationFactory(JwtTokenProvider provider) { + this.provider = provider; + } /** * Creates an implementation of the {@link Authentication} interface which implements the @@ -62,7 +65,7 @@ public Mono createAuthentication(String token) { log.debug("Authenticating token {}...", token.substring(0, Math.min(MAX_TOKEN_LEN_LOG, token.length()))); try { DecodedJWT jwt = provider.decode(token); - List authorities = AuthorityUtils.createAuthorityList( + List authorities = AuthorityUtils.createAuthorityList( jwt.getClaim(JwtTokenProvider.ROLES).asArray(String.class)); String subject = jwt.getSubject(); @@ -72,6 +75,12 @@ public Mono createAuthentication(String token) { } catch (JWTVerificationException exception) { log.warn("Cannot validate API Token: {}", exception.getMessage()); return Mono.error(new BadCredentialsException("API Token invalid", exception)); + } catch (IllegalArgumentException exception) { + log.warn("The Token is malformed: {}", exception.getMessage()); + return Mono.error(new BadCredentialsException("API Token malformed", exception)); + } catch (Exception ex) { + log.error("Unexpected error while validating token: {}", ex.getMessage()); + return Mono.error(new BadCredentialsException("API Token malformed")); } } } diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java b/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java index 1b57949..b09ad22 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/OpaReactiveAuthorizationManager.java @@ -105,7 +105,7 @@ public Mono check( String path = request.getPath().toString(); for (String pattern : authRoutes) { if (pathMatcher.match(pattern, path)) { - log.debug("Route is allowed to bypass authorization"); + log.debug("Route {} is allowed to bypass authorization (matches: {})", path, pattern); return Mono.just(new AuthorizationDecision(true)); } } diff --git a/run-example.sh b/run-example.sh index df5384f..a10b84b 100755 --- a/run-example.sh +++ b/run-example.sh @@ -5,22 +5,19 @@ # http://www.apache.org/licenses/LICENSE-2.0 # # Author: Marco Massenzio (marco@alertavert.com) - set -eu WORKDIR=$(dirname $0) - OPA_PORT=8181 OPA_SERVER=http://localhost:${OPA_PORT} POLICY_API=${OPA_SERVER}/v1/policies/userauth -docker-compose up -d +docker compose --project-name jwt-opa up -d if [[ $(curl -s ${POLICY_API} | jq .result.id) != "userauth" ]]; then echo "Uploading userauth Policy" curl -T "${WORKDIR}/webapp-example/src/main/rego/jwt_auth.rego" -X PUT ${POLICY_API} fi -export SPRING_PROFILES_ACTIVE="debug" - echo "Containers started, starting server..." +export SPRING_PROFILES_ACTIVE="${1:-dev}" ${WORKDIR}/gradlew :webapp-example:bootRun diff --git a/webapp-example/build.gradle b/webapp-example/build.gradle index c57b489..cc406d0 100644 --- a/webapp-example/build.gradle +++ b/webapp-example/build.gradle @@ -19,12 +19,12 @@ plugins { id 'java' id 'jacoco' - id 'org.springframework.boot' version '2.5.7' - id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'org.springframework.boot' version '3.1.5' + id 'io.spring.dependency-management' version '1.1.3' } group 'com.alertavert.opademo' -version = "0.3.0" +version = "0.4.0" repositories { // Adding local repository for Gradle to find jwt-opa before it gets published. @@ -36,7 +36,7 @@ repositories { ext { // This can be changed to an yet-unpublished version by using mavenLocal() // for local tests. - jwtOpaVersion = "0.9.0" + jwtOpaVersion = "0.10.0" lombokVersion = "1.18.22" tcVersion = "1.15.1" } @@ -48,9 +48,14 @@ bootJar { dependencies { // We use the actual dependency here, instead of depending on the module in the repository so // as to emulate an actual project using jwt-opa externally. + // Uncomment the following line (and comment out the one below) to use the local version + // while developing. implementation project (':jwt-opa') // implementation "com.alertavert:jwt-opa:${jwtOpaVersion}" + // For the @PostConstruct annotation + implementation 'javax.annotation:javax.annotation-api:1.3.2' + compileOnly "org.projectlombok:lombok:${lombokVersion}" annotationProcessor "org.projectlombok:lombok:${lombokVersion}" @@ -64,6 +69,9 @@ dependencies { implementation 'org.bouncycastle:bcprov-jdk15on:1.70' implementation 'commons-codec:commons-codec:1.13' + // For the @PostConstruct annotation + implementation 'javax.annotation:javax.annotation-api:1.3.2' + // Swagger 2 API & UI // "Raw" JSON at http://localhost:8081/v2/api-docs // UI at http://localhost:8081/swagger-ui/ (trailing slash matters) diff --git a/webapp-example/src/main/java/com/alertavert/opademo/DbInit.java b/webapp-example/src/main/java/com/alertavert/opademo/DbInit.java index 2b64cb0..a56367e 100644 --- a/webapp-example/src/main/java/com/alertavert/opademo/DbInit.java +++ b/webapp-example/src/main/java/com/alertavert/opademo/DbInit.java @@ -21,9 +21,7 @@ import com.alertavert.opademo.api.UserController; import com.alertavert.opademo.data.User; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -35,12 +33,10 @@ /** * Initializes the DB with a seed `admin` user and a random password, if it doesn't already exist. */ -@Profile("debug") @Slf4j @Component public class DbInit { - @Autowired - UserController controller; + private final UserController controller; @Value("${db.admin.username:admin}") String adminUsername; @@ -48,6 +44,10 @@ public class DbInit { @Value("${db.admin.password}") String adminPassword; + public DbInit(UserController controller) { + this.controller = controller; + } + @PostConstruct public void initDb() { @@ -57,7 +57,7 @@ public void initDb() { adminUsername, adminPassword); } User admin = new User(adminUsername, adminPassword, "SYSTEM"); - + log.info("Creating admin user: {}", adminUsername); controller.create(admin) .doOnSuccess(responseEntity -> { if (!responseEntity.getStatusCode().equals(HttpStatus.CREATED)) { @@ -68,7 +68,7 @@ public void initDb() { } }) .doOnError(ResponseStatusException.class, ex -> { - if (ex.getStatus().equals(HttpStatus.CONFLICT)) { + if (ex.getStatusCode().equals(HttpStatus.CONFLICT)) { log.info("User [{}] already exists in database, use existing credentials", adminUsername); } else { @@ -76,6 +76,7 @@ public void initDb() { System.exit(1); } }) + .onErrorComplete() .subscribe(); } } diff --git a/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java b/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java index f95f367..6200f7e 100644 --- a/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java +++ b/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java @@ -24,6 +24,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.jackson.Jacksonized; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -54,14 +56,7 @@ public JwtController(JwtTokenProvider provider, ReactiveUsersRepository reposito this.repository = repository; } - @Data - @AllArgsConstructor - static class ApiToken { - String username; - List roles; - @JsonProperty(API_TOKEN) - String apiToken; - } + record ApiToken(String username, List roles, @JsonProperty(API_TOKEN) String apiToken) { } @GetMapping(path = "/token/{user}", produces = MimeTypeUtils.APPLICATION_JSON_VALUE) public Mono> getToken(@PathVariable String user) { diff --git a/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java b/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java index bc700e3..cb09e5b 100644 --- a/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java +++ b/webapp-example/src/main/java/com/alertavert/opademo/api/LoginController.java @@ -18,16 +18,12 @@ package com.alertavert.opademo.api; -import com.alertavert.opademo.DbInit; -import com.alertavert.opademo.data.ReactiveUsersRepository; import com.alertavert.opa.jwt.JwtTokenProvider; +import com.alertavert.opademo.data.ReactiveUsersRepository; import com.alertavert.opademo.data.User; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.util.Base64Utils; import org.springframework.util.MimeTypeUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -38,6 +34,7 @@ import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.UUID; import static com.alertavert.opa.Constants.BASIC_AUTH; @@ -56,14 +53,13 @@ consumes = MimeTypeUtils.ALL_VALUE) public class LoginController { - @Autowired - JwtTokenProvider provider; + private final JwtTokenProvider provider; + private final ReactiveUsersRepository repository; - @Autowired - ReactiveUsersRepository repository; - - @Autowired - PasswordEncoder encoder; + public LoginController(JwtTokenProvider provider, ReactiveUsersRepository repository) { + this.provider = provider; + this.repository = repository; + } @GetMapping @@ -82,7 +78,7 @@ Mono login( }) .doOnNext(apiToken -> log.debug("User authenticated, user = {}, token = {}...", - apiToken.getUsername(), apiToken.getApiToken().substring(0, MAX_TOKEN_LEN_LOG))); + apiToken.username(), apiToken.apiToken().substring(0, MAX_TOKEN_LEN_LOG))); } @GetMapping("/reset/{username}") @@ -114,7 +110,7 @@ public static Mono usernameFromHeader(String credentials) { log.debug("Extracting username from Authorization header"); if (credentials.startsWith(BASIC_AUTH)) { return Mono.just(credentials.substring(BASIC_AUTH.length() + 1)) - .map(enc -> Base64Utils.decode(enc.getBytes(StandardCharsets.UTF_8))) + .map(enc -> Base64.getDecoder().decode(enc.getBytes(StandardCharsets.UTF_8))) .map(String::new) .map(creds -> { String[] userPass = creds.split(":"); @@ -126,7 +122,7 @@ public static Mono usernameFromHeader(String credentials) { } public static Mono credentialsToHeader(String credentials) { - String encoded = Base64Utils.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); return Mono.just(String.format("%s %s", BASIC_AUTH, encoded)); } } diff --git a/webapp-example/src/main/resources/application.yaml b/webapp-example/src/main/resources/application.yaml index 83853fe..3150964 100644 --- a/webapp-example/src/main/resources/application.yaml +++ b/webapp-example/src/main/resources/application.yaml @@ -101,7 +101,7 @@ keys: # For a PASSPHRASE, the secret is simply read from SecretsManager/Vault # The keypair is stored as a JSON-formatted secret, with two keys: "priv" and "pub". location: keypair - name: ../private/ec-key-1 + name: private/ec-key logging: level: @@ -131,7 +131,7 @@ routes: - "/health" - "/demo" - "/favicon.ico" - - "/login/reset/*" + #- "/login/reset/*" # These will require the user to authenticate, but will not # be subject to OPA Policies authorization check. diff --git a/webapp-example/src/main/resources/banner.txt b/webapp-example/src/main/resources/banner.txt new file mode 100644 index 0000000..870fb12 --- /dev/null +++ b/webapp-example/src/main/resources/banner.txt @@ -0,0 +1,6 @@ + /\ /\ /\ ____.__ _____________ ________ __________ _____ /\ /\ /\ + / / / / / / | / \ / \__ ___/ \_____ \\______ \/ _ \ \ \ \ \ \ \ + / / / / / / | \ \/\/ / | | ______ / | \| ___/ /_\ \ \ \ \ \ \ \ + / / / / / / /\__| |\ / | | /_____/ / | \ | / | \ \ \ \ \ \ \ + / / / / / / \________| \__/\ / |____| \_______ /____| \____|__ / \ \ \ \ \ \ + \/ \/ \/ \/ \/ \/ \/ \/ \/ diff --git a/webapp-example/src/test/java/com/alertavert/opademo/api/LoginControllerTest.java b/webapp-example/src/test/java/com/alertavert/opademo/api/LoginControllerTest.java index 834e67f..874ade0 100644 --- a/webapp-example/src/test/java/com/alertavert/opademo/api/LoginControllerTest.java +++ b/webapp-example/src/test/java/com/alertavert/opademo/api/LoginControllerTest.java @@ -38,17 +38,6 @@ class LoginControllerTest { User bob, pete; - /** - * Takes a User with the password field in plaintext, and converts into a hashed one, then saves - * it to the DB. - * - * @param user - * @return the same user, but with a hashed password - */ - private Flux hashPasswordAndSave(User user) { - return hashPasswordAndSaveAll(List.of(user)); - } - private Flux hashPasswordAndSaveAll(List users) { return repository.saveAll( users.stream() @@ -72,18 +61,18 @@ public void validUserSuccessfullyLogin() { .exchange() .expectStatus().isOk() .expectBody(JwtController.ApiToken.class) - .value(t -> assertThat(t.getUsername().equals("bob"))) + .value(t -> assertThat(t.username().equals("bob"))) .returnResult() .getResponseBody(); assertThat(apiToken).isNotNull(); - assertThat(apiToken.getUsername()).isEqualTo("bob"); - assertThat(apiToken.getRoles()).contains("USER"); - assertThat(apiToken.getApiToken()).isNotEmpty(); + assertThat(apiToken.username()).isEqualTo("bob"); + assertThat(apiToken.roles()).contains("USER"); + assertThat(apiToken.apiToken()).isNotEmpty(); } @Test - public void validUserWrongPwdFailsLogin() { + void validUserWrongPwdFailsLogin() { client.get() .uri("/login") .header(HttpHeaders.AUTHORIZATION, LoginController.credentialsToHeader("bob:foo").block()) @@ -92,7 +81,7 @@ public void validUserWrongPwdFailsLogin() { } @Test - public void invalidUserFailsLogin() { + void invalidUserFailsLogin() { client.get() .uri("/login") .header(HttpHeaders.AUTHORIZATION, @@ -102,7 +91,7 @@ public void invalidUserFailsLogin() { } @Test - public void validUserCanResetPassword() { + void validUserCanResetPassword() { client.get() .uri("/login/reset/pete") .exchange() diff --git a/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java b/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java index 1f33a82..fd53aa5 100644 --- a/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java +++ b/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java @@ -106,7 +106,7 @@ void findByRole() { } @Test - void jsongen() throws JsonProcessingException { + void jsongen() throws Exception { ObjectMapper mapper = new ObjectMapper(); User me = new User("me", "myself", "USER");