├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── assets └── images │ ├── login.gif │ ├── register.gif │ └── screengrab.png ├── config ├── config.yml └── credentials.yml ├── go.mod ├── go.sum ├── main.go ├── static ├── authenticated.html ├── clipboard.png ├── favicon.ico ├── index.html ├── jquery-3.6.3.min.js ├── login.html ├── register.html ├── script.js ├── styles.css └── title-image.png ├── user └── user.go ├── util └── util.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dev_config/ 3 | webauthn_proxy 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ### 0.2 (2024-12-11) 4 | 5 | * Add new config options `userCookieName`, `sessionCookieName`, `cookieSecure` to customize cookie names. 6 | * Update Go version, alpine and all dependencies. 7 | 8 | ### 0.1 (2024-04-17) 9 | 10 | * Upgrade Go version to 1.22.2, alpine to 3.19 and other dependencies. 11 | * Disable unnecessary 1Password auto-filling. 12 | 13 | ### 0.0.4 (2023-02-22) 14 | 15 | * Upgrade Go version to 1.19.6, alpine to 3.16 and other dependencies. 16 | * Switch from `github.com/duo-labs/webauthn` to `github.com/go-webauthn/webauthn` 17 | * Implement better logging. 18 | * Build multiarch Docker image including ARM now. 19 | * Make cookie secrets configurable in credentials.yml so sessions can persist proxy restarts. 20 | * Static files are now only served from `/webauthn/static/` and no directory index available. 21 | * Add cmd flags to generate cookie secrets, enable debug logging etc. 22 | * Credentials are now stored in `credentials.yml` by default in the same folder as `config.yml`. 23 | Remove config variable for it. Both, `config.yml` and `credentials.yml` are expected in the relative 24 | `config/` dir or the dir defined as the env var `WEBAUTHN_PROXY_CONFIGPATH` as previously. 25 | * Better usability to quickly run w/o any config changes. 26 | * Forbid storing credentials under the wrong user. Email should match credentials login name. 27 | * Endpoint `/webauthn/auth` will now return `X-Authenticated-User` header to know who authenticated and 28 | to use that information further in nginx config for whatever purpose. 29 | * Add `/webauthn/logout` endpoint, basically deletes the session cookie. 30 | * Add `/webauthn/verify` endpoint to perform user authentication verification. 31 | * Improve login page and redirect from any invalid path to the login page including / 32 | Useful as a 2FA check for external systems. 33 | For example, call to `http://localhost:8080/webauthn/verify?username=email@example.com&ip=127.0.0.1` 34 | returns ok if user is authenticated within the past 5 min. Also you need to specify IP address from where user did that. It doesn't have anything to do with the cookie session. It can be called from an external system. 35 | Once verified it can't be verified again. If IP mismatches nothing will happen but the result fails to verify. 36 | * Store last logged username into a separate cookie, other minor tweaks for convenience. 37 | * Fix session expiration by the hard limit. 38 | * Fix JS decoding error after switching to `github.com/go-webauthn/webauthn` and messages for other JS errors. 39 | * Compatibility with Chrome, Firefox and Safari. However, Touch ID only works in Chrome. 40 | 41 | ### 0.0.3 (2022-06-30) 42 | 43 | * Initial public version. 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.4-alpine3.21 as builder 2 | 3 | WORKDIR /opt/src 4 | ADD . /opt/src 5 | RUN go build -o /opt/webauthn_proxy . 6 | 7 | 8 | FROM alpine:3.21 9 | 10 | WORKDIR /opt 11 | ADD config /opt/config 12 | ADD static /opt/static 13 | 14 | COPY --from=builder /opt/webauthn_proxy /opt/webauthn_proxy 15 | RUN chown -R root:nobody /opt 16 | 17 | EXPOSE 8080 18 | USER nobody 19 | ENTRYPOINT ["/opt/webauthn_proxy"] 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE=quiq/webauthn_proxy 2 | VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go` 3 | NOCACHE=--no-cache 4 | 5 | .DEFAULT_GOAL := dummy 6 | 7 | dummy: 8 | @echo "Nothing to do here." 9 | 10 | build: 11 | docker build ${NOCACHE} -t ${IMAGE}:${VERSION} . 12 | 13 | public: 14 | docker buildx build ${NOCACHE} --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push . 15 | 16 | test: 17 | docker buildx build ${NOCACHE} --platform linux/arm64 -t docker.quiq.sh/webauthn_proxy:test --push . 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![WebAuthn Proxy Login Page](/assets/images/login.gif) 2 | 3 | A standalone reverse-proxy to enforce Webauthn authentication. It can be inserted in front of sensitive services or even chained with other proxies (e.g. OAuth, MFA) to enable a layered security model. 4 | 5 | Webauthn is a passwordless, public key authentication mechanism that allows the use of hardware-based authenticators such as Yubikey, Apple Touch ID or Windows Hello. You can learn more about Webauthn [here](https://webauthn.guide/). 6 | 7 | ## Goals 8 | We specifically built this proxy to fit into our ecosystem and we hope that it might be useful for other teams. Our aim was to make a Webauthn module that was configurable and manageable using standard DevOps tools (in our case Docker and Ansible) and which could be easily inserted into our existing service deployments behind a reverse proxy like NGinx/OpenResty, and chained with other similar security proxies that we use such as [OAuth2 Proxy](https://github.com/oauth2-proxy/). 9 | 10 | 11 | ## Getting Started 12 | First thing you will need to do is build the project. See instructions [below](#building) for building the Go code directly, or using Docker. It is also available on [on Dockerhub](https://hub.docker.com/r/quiq/webauthn_proxy) if you don't want to build it yourself. 13 | 14 | By default the proxy will look for the config file `config.yml` and credentials file `credentials.yml` in 15 | `config/`, which is `/opt/config` in the Docker image but you can also override this by setting the `WEBAUTHN_PROXY_CONFIGPATH` environment variable to another directory. 16 | 17 | `credentials.yml` file is a simple YAML file with key-value pairs of username to credential. The credential is a base64 encoded JSON object which is output during the registration process. You can start with an empty credentials file until you've registered your first user. 18 | 19 | Now you can start the proxy. See instructions [below](#running) for running it directly, or using Docker. Once it's started you can register a user by going to _http://localhost:8080/webauthn/register_ (assuming you used 8080 as the server port). Enter a username and then click _Register_. You will be prompted to select an authenticator to register, which is a browser dependent operation (see below). After following the prompts, you will be given a username/credential combination. You should add this entry to the credentials file and restart the proxy. 20 | 21 | ![WebAuthn Proxy Registration](/assets/images/register.gif) 22 | 23 | After registration you can go to _http://localhost:8080/webauthn/login_ to log in. Enter the same username you registered and click _Login_. You will be prompted to provide your authenticator device. Again follow the prompts and you should be successfully authenticated. 24 | 25 | At this point you have it running locally. To configure it to work in your environment you will need to configure your webserver or reverse-proxy to make calls to it in order to authenticate. You can use the `/webauthn/auth` endpoint to check if the caller is currently authenticated, and `/webauthn/login` (with optional `redirect_url` and `default_username` URL parameters) for the the user to login. See instructions [below](#using) for examples of configuration with NGinx and OpenResty. 26 | 27 | 28 | ## Supported Browsers and Authenticators 29 | Firefox and Chrome have been tested and work well, there is some differences in their supported authentication methods. You can some helpful info [here](https://webauthn.me/browser-support) and [here](https://help.okta.com/en/prod/Content/Topics/Security/mfa-webauthn.htm). Note that you can register multiple different authenticators for a single user, which can be helpful for contingencies such as lost or broken devices. 30 | 31 | Other browsers have not been tested but likely will function just fine if they support Webauthn; please feel free to open a pull request to this document with your own testing details. 32 | 33 | ## Running 34 | #### Golang 35 | ``` 36 | go run . 37 | WEBAUTHN_PROXY_CONFIGPATH=/path/to/config/ go run . 38 | ``` 39 | 40 | #### Docker 41 | ``` 42 | docker run --rm -ti -p 8080:8080 quiq/webauthn_proxy:latest 43 | docker run --rm -ti -p 8080:8080 -v /path/to/config:/opt/config:ro quiq/webauthn_proxy:latest 44 | ``` 45 | To generate cookie secret to add to `credentials.yml`: 46 | ``` 47 | docker run --rm -ti quiq/webauthn_proxy:latest -generate-secret 48 | 49 | ``` 50 | 51 | ## Building yourself 52 | #### Golang 53 | ``` 54 | go build -o webauthn_proxy . && chmod +x webauthn_proxy 55 | ./webauthn_proxy -v 56 | ``` 57 | Note, to run it elsewhere you will also need `config/` and `static/` dirs. 58 | 59 | #### Docker 60 | ``` 61 | docker build -t webauthn_proxy:custom . 62 | ``` 63 | 64 | ## Using 65 | You can configure this as an authentication reverse-proxy using the sample configuration for NGinx or Openresty below. Other proxies and webservers haven't been tested currently but they should work and if you have done so please feel free to open a pull request to this document with details. 66 | 67 | #### NGinx 68 | ``` 69 | location / { 70 | auth_request /webauthn/auth; 71 | error_page 401 = /webauthn/login?redirect_url=$uri; 72 | 73 | # ... 74 | } 75 | 76 | # WebAuthn Proxy. 77 | location /webauthn/ { 78 | proxy_set_header X-Forwarded-Proto $scheme; 79 | proxy_set_header Host $host; 80 | proxy_pass http://127.0.0.1:8080; 81 | } 82 | ``` 83 | 84 | #### OpenResty (example of chaining WebAuthn proxy with [OAuth2 Proxy](https://github.com/oauth2-proxy/oauth2-proxy)) 85 | ``` 86 | location / { 87 | auth_request /oauth2/auth; 88 | 89 | # Get the email from oauth2 proxy to prepolulate with the redirect below 90 | auth_request_set $email $upstream_http_x_auth_request_email; 91 | error_page 401 = /oauth2/start?rd=$uri; 92 | access_by_lua_block { 93 | local http = require "resty.http" 94 | local h = http.new() 95 | h:set_timeout(5 * 1000) 96 | local url = "http://127.0.0.1:8080/webauthn/auth" 97 | ngx.req.set_header("X-Forwarded-Proto", ngx.var.scheme) 98 | ngx.req.set_header("Host", ngx.var.host) 99 | local res, err = h:request_uri(url, {method = "GET", headers = ngx.req.get_headers()}) 100 | if err or not res or res.status ~= 200 then 101 | # Redirect to webauthn login, with email as the default username 102 | ngx.redirect("/webauthn/login?redirect_url=" .. ngx.var.request_uri .. "&default_username=" .. ngx.var.email) 103 | ngx.exit(ngx.HTTP_OK) 104 | end 105 | } 106 | 107 | # ... 108 | } 109 | 110 | # OAuth2 Proxy. 111 | location = /oauth2/auth { 112 | internal; 113 | proxy_pass http://127.0.0.1:4180; 114 | } 115 | location /oauth2/ { 116 | proxy_pass http://127.0.0.1:4180; 117 | } 118 | 119 | # WebAuthn Proxy. 120 | location /webauthn/ { 121 | proxy_set_header X-Forwarded-Proto $scheme; 122 | proxy_set_header Host $host; 123 | proxy_pass http://127.0.0.1:8080; 124 | } 125 | ``` 126 | 127 | ## Important Configuration Options 128 | All configuration options have a sensible default value and thus can be left off except `rpID` and `rpDisplayName`, which you must provide. There are a few important options that you should be aware though: 129 | 130 | `rpDisplayName`: Can be anything you want, a descriptive name of the "relying party", usually organization name. 131 | 132 | `rpID`: Should be set to the domain that your services operate under, for example if you want to secure your CI system and code repositories at _https://ci.example.com_ and _https://code.example.com_, you should set `rpID` to simply `example.com`. This will allow both sites to share the same set of credentials. **Note:** Credentials created while running the proxy with one `rpID` are not usable under another. 133 | 134 | `rpOrigins`: If left empty, the proxy will dynamically allow requests to any origin, otherwise it will only allow the configured origins. For example, if you only want this proxy to support _https://ci.example.com_ and _https://code.example.com_, use the following configuration: 135 | ``` 136 | rpOrigins: 137 | - https://ci.example.com 138 | - https://code.example.com 139 | ``` 140 | 141 | Otherwise, if you wanted it to work for any service under _example.com_, you could simply leave `rpOrigins` out of your config. 142 | 143 | `serverAddress`: The address the proxy should listen on. Typically this would be _127.0.0.1_ if you are running it locally or behind another webserver or proxy, or _0.0.0.0_ if you are running in Docker or wanted to expose it directly to the world. 144 | 145 | `testMode`: By setting this value to `true`, a user will be able to authenticate immediately after they have registered without any intervention from a system administrator, until the proxy is restarted. This is useful for testing, but we highly recommend you set this property to `false` in production, otherwise users will be able to register themselves and then immediately authenticate. 146 | 147 | 148 | ## All Configuration Options 149 | | Option | Description | Default | 150 | | ------ | ----------- | ------- | 151 | | **rpDisplayName** | Display name of relying party | MyCompany | 152 | | **rpID** | ID of the relying party, usually the domain the proxy and callers live under | localhost | 153 | | rpOrigins | Array of full origins used for accessing the proxy, including port if not 80/443, e.g. http://service.example.com:8080. | All Origins | 154 | | serverAddress | Address the proxy server should listen on (usually 127.0.0.1 or 0.0.0.0) | 0.0.0.0 | 155 | | serverPort | Port the proxy server should listen on | 8080 | 156 | | sessionSoftTimeoutSeconds | Length of time logins are valid for, in seconds | 28800 (8 hours) | 157 | | sessionHardTimeoutSeconds | Max length of logged in session, as calls to /webauthn/auth reset the session timeout | 86400 (24 hours) | 158 | | sessionCookieName | Change the name of the session cookie | webauthn-proxy-session | 159 | | userCookieName | Change the name of the username cookie | webauthn-proxy-username | 160 | | testMode | When set to **_true_**, users can authenticate immediately after registering. Useful for testing, but generally not safe for production. | false | 161 | | usernameRegex | Regex for validating usernames | ^.+$ | 162 | | cookieSecure | When set to **_true_**, enables the Secure flag for cookies. Useful when running behind a TLS reverse proxy. | false | 163 | | cookieDomain | The domain to be used for cookies. Useful when running WebAuthn Proxy for multiple subdomains. | Domain of the page the user is visiting | 164 | 165 | 166 | ## Thanks! 167 | - Duo Labs: https://duo.com/labs 168 | - Herbie Bolimovsky: https://www.herbie.dev/blog/webauthn-basic-web-client-server/ 169 | - Paul Hankin / icza: https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go 170 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | To report any security issues, please use this email address security@quiq.com 2 | -------------------------------------------------------------------------------- /assets/images/login.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/webauthn_proxy/45bc92dffc24debc7e7c7873586676870efcee9f/assets/images/login.gif -------------------------------------------------------------------------------- /assets/images/register.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/webauthn_proxy/45bc92dffc24debc7e7c7873586676870efcee9f/assets/images/register.gif -------------------------------------------------------------------------------- /assets/images/screengrab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/webauthn_proxy/45bc92dffc24debc7e7c7873586676870efcee9f/assets/images/screengrab.png -------------------------------------------------------------------------------- /config/config.yml: -------------------------------------------------------------------------------- 1 | ## rpDisplayName - Display name of the relying party 2 | rpDisplayName: "MyCompany" 3 | 4 | ## rpID - ID of the relying party, a valid domain string usually the domain the proxy and callers live under. 5 | ## By default, the RP ID for a WebAuthn operation is set to the caller’s origin's effective domain. 6 | ## This default MAY be overridden by the caller, as long as the caller-specified RP ID value 7 | ## is a registrable domain suffix of or is equal to the caller’s origin's effective domain. 8 | rpID: localhost 9 | 10 | ## rpOrigins - Array of full origins used for accessing the proxy, including port if not 80/443 11 | rpOrigins: 12 | # - http://localhost:8080 13 | # - https://service.example.com 14 | 15 | ## serverAddress - Address the proxy server should listen on (usually 127.0.0.1 or 0.0.0.0) 16 | ## Note, it should be 0.0.0.0 when running in Docker and network mode is not "host". 17 | # serverAddress: 0.0.0.0 18 | 19 | ## serverPort - Port the proxy server should listen on 20 | # serverPort: 8080 21 | 22 | ## sessionSoftTimeoutSeconds - Length of time logins are valid for, in seconds 23 | # sessionSoftTimeoutSeconds: 28800 24 | 25 | ## sessionHardTimeoutSeconds - Max length of logged in session, as calls to /webauthn/auth reset the session timeout 26 | # sessionHardTimeoutSeconds: 86400 27 | 28 | ## testMode - When set to 'true', users can authenticate immediately 29 | ## after registering. Useful for testing, but generally not safe for production. 30 | # testMode: false 31 | 32 | ## SessionCookieName - Change the name of the session cookie 33 | # sessionCookieName: "webauthn-proxy-session" 34 | 35 | ## UserCookieName - Change the name of the username cookie 36 | # userCookieName: "webauthn-proxy-username" 37 | 38 | ## usernameRegex - Regex for validating usernames 39 | ## The following regex will allow usernames with uppercase, lowercase, digits, or "-.@" ... 40 | ## Basically, email addresses 41 | # usernameRegex: ^[A-Za-z0-9\-\_\.\@]+$ 42 | 43 | ## cookieSecure - When set to 'true', enables the Secure flag for cookies. 44 | ## Useful when running behind a TLS reverse proxy. 45 | # cookieSecure: false 46 | 47 | ## cookieDomain - The domain to be used for cookies. 48 | ## Useful when running WebAuthn Proxy for multiple subdomains. 49 | # cookieDomain: example.com 50 | -------------------------------------------------------------------------------- /config/credentials.yml: -------------------------------------------------------------------------------- 1 | # Encryption key for cookie session. It is recommended to use key with 32 or 64 bytes. 2 | # New sessions will be saved using the first key. Multiple keys can be provided to allow for key rotation. 3 | # If you don't set any it will be dynamic and will not persist proxy restarts (users will need to re-login). 4 | # This is a list, please preserve "-" with each row. 5 | cookie_session_secrets: 6 | # - your-own-cookie-secret 7 | 8 | # User credentials as obtained from the registration. 9 | # This is a dict, please put an indentation with each row. 10 | user_credentials: 11 | # email1@example.com: encodeeeed-secret-data1 12 | # email2@example.com: encodeeeed-secret-data2 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Quiq/webauthn_proxy 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/go-webauthn/webauthn v0.11.2 7 | github.com/gorilla/sessions v1.4.0 8 | github.com/sirupsen/logrus v1.9.3 9 | github.com/spf13/viper v1.19.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/fsnotify/fsnotify v1.8.0 // indirect 15 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 16 | github.com/go-webauthn/x v0.1.15 // indirect 17 | github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 18 | github.com/google/go-tpm v0.9.1 // indirect 19 | github.com/google/uuid v1.6.0 // indirect 20 | github.com/gorilla/securecookie v1.1.2 // indirect 21 | github.com/hashicorp/hcl v1.0.0 // indirect 22 | github.com/magiconair/properties v1.8.9 // indirect 23 | github.com/mitchellh/mapstructure v1.5.0 // indirect 24 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 25 | github.com/sagikazarmark/locafero v0.6.0 // indirect 26 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 27 | github.com/sourcegraph/conc v0.3.0 // indirect 28 | github.com/spf13/afero v1.11.0 // indirect 29 | github.com/spf13/cast v1.7.0 // indirect 30 | github.com/spf13/pflag v1.0.5 // indirect 31 | github.com/subosito/gotenv v1.6.0 // indirect 32 | github.com/x448/float16 v0.8.4 // indirect 33 | go.uber.org/multierr v1.11.0 // indirect 34 | golang.org/x/crypto v0.30.0 // indirect 35 | golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect 36 | golang.org/x/sys v0.28.0 // indirect 37 | golang.org/x/text v0.21.0 // indirect 38 | gopkg.in/ini.v1 v1.67.0 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 4 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 6 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 7 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 8 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 9 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 10 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 11 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 12 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 13 | github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= 14 | github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= 15 | github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0= 16 | github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc= 17 | github.com/go-webauthn/x v0.1.15 h1:eG1OhggBJTkDE8gUeOlGRbRe8E/PSVG26YG4AyFbwkU= 18 | github.com/go-webauthn/x v0.1.15/go.mod h1:pf7VI23raFLHPO9VVIs9/u1etqwAOP0S2KoHGL6WbZ8= 19 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 20 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 21 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 22 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 23 | github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= 24 | github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 25 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 26 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 27 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 28 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 29 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 30 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 31 | github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 32 | github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 33 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 34 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 35 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 36 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 37 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 38 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 39 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 40 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 41 | github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= 42 | github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 43 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 44 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 45 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 46 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 47 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 48 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 51 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 52 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 53 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 54 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 55 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 56 | github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= 57 | github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= 58 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 59 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 60 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 61 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 62 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 63 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 64 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 65 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 66 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 67 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 68 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 69 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 70 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 71 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 72 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 73 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 74 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 75 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 76 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 77 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 78 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 79 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 80 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 81 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 82 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 83 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 84 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 85 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 86 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 87 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 88 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 89 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 90 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 91 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 92 | golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= 93 | golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 94 | golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= 95 | golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 96 | golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= 97 | golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= 98 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 100 | golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 101 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 102 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 103 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 104 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 105 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 106 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 107 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 108 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 109 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 110 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 111 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 112 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 113 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 114 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 115 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | u "github.com/Quiq/webauthn_proxy/user" 15 | util "github.com/Quiq/webauthn_proxy/util" 16 | 17 | "github.com/go-webauthn/webauthn/webauthn" 18 | "github.com/gorilla/sessions" 19 | "github.com/sirupsen/logrus" 20 | "github.com/spf13/viper" 21 | yaml "gopkg.in/yaml.v3" 22 | ) 23 | 24 | type Configuration struct { 25 | RPDisplayName string // Relying party display name 26 | RPID string // Relying party ID 27 | RPOrigins []string // Relying party origin 28 | 29 | // Note: enabling this can be risky as it allows anyone to add themselves to the proxy. 30 | // Only enable test mode during testing! 31 | TestMode bool 32 | 33 | ServerAddress string 34 | ServerPort string 35 | SessionSoftTimeoutSeconds int 36 | SessionHardTimeoutSeconds int 37 | SessionCookieName string 38 | UserCookieName string 39 | UsernameRegex string 40 | CookieSecure bool 41 | CookieDomain string 42 | } 43 | 44 | type CredentialsConfiguration struct { 45 | CookieSecrets []string `yaml:"cookie_session_secrets"` 46 | Credentials map[string]string `yaml:"user_credentials"` 47 | } 48 | 49 | type WebAuthnMessage struct { 50 | Message string 51 | } 52 | 53 | type RegistrationSuccess struct { 54 | Message string 55 | Data string 56 | } 57 | 58 | type LoginVerification struct { 59 | IPAddr string 60 | LoginTime time.Time 61 | } 62 | 63 | const ( 64 | AuthenticatedUsernameHeader = "X-Authenticated-User" 65 | loginVerificationInterval = 5 * time.Minute 66 | staticPath = "static/" 67 | ) 68 | 69 | var ( 70 | configuration Configuration 71 | loginError WebAuthnMessage 72 | registrationError WebAuthnMessage 73 | authError WebAuthnMessage 74 | users map[string]u.User 75 | registrations map[string]u.User 76 | cookieSecrets []string 77 | dynamicOrigins bool 78 | webAuthns map[string]*webauthn.WebAuthn 79 | sessionStores map[string]*sessions.CookieStore 80 | loginVerifications map[string]*LoginVerification 81 | logger *logrus.Entry 82 | ) 83 | 84 | func main() { 85 | var ( 86 | genSecretFlag, versionFlag bool 87 | loggingLevel string 88 | ) 89 | flag.StringVar(&loggingLevel, "log-level", "info", "logging level") 90 | flag.BoolVar(&genSecretFlag, "generate-secret", false, "generate a random string suitable as a cookie secret") 91 | flag.BoolVar(&versionFlag, "version", false, "show version") 92 | flag.Parse() 93 | logger = util.SetupLogging("webauthn_proxy", loggingLevel) 94 | 95 | if genSecretFlag { 96 | fmt.Println(util.GenChallenge()) 97 | return 98 | } else if versionFlag { 99 | fmt.Println(version) 100 | return 101 | } 102 | 103 | var err error 104 | var credfile []byte 105 | var credentialsConfig CredentialsConfiguration 106 | // Standard error messages 107 | loginError = WebAuthnMessage{Message: "Unable to login"} 108 | registrationError = WebAuthnMessage{Message: "Error during registration"} 109 | authError = WebAuthnMessage{Message: "Unauthenticated"} 110 | 111 | users = make(map[string]u.User) 112 | registrations = make(map[string]u.User) 113 | webAuthns = make(map[string]*webauthn.WebAuthn) 114 | sessionStores = make(map[string]*sessions.CookieStore) 115 | loginVerifications = make(map[string]*LoginVerification) 116 | 117 | // Set configuration defaults 118 | viper.SetDefault("configpath", "./config") 119 | viper.SetEnvPrefix("webauthn_proxy") 120 | viper.BindEnv("configpath") 121 | viper.SetConfigName("config") 122 | viper.SetConfigType("yml") 123 | 124 | viper.SetDefault("rpdisplayname", "MyCompany") 125 | viper.SetDefault("rpid", "localhost") 126 | viper.SetDefault("rporigins", []string{}) 127 | viper.SetDefault("testmode", false) 128 | viper.SetDefault("serveraddress", "0.0.0.0") 129 | viper.SetDefault("serverport", "8080") 130 | viper.SetDefault("sessionsofttimeoutseconds", 28800) 131 | viper.SetDefault("sessionhardtimeoutseconds", 86400) 132 | viper.SetDefault("sessioncookiename", "webauthn-proxy-session") 133 | viper.SetDefault("usercookiename", "webauthn-proxy-username") 134 | viper.SetDefault("usernameregex", "^.+$") 135 | viper.SetDefault("cookiesecure", false) 136 | viper.SetDefault("cookiedomain", "") 137 | 138 | // Read in configuration file 139 | configpath := viper.GetString("configpath") 140 | viper.AddConfigPath(configpath) 141 | logger.Infof("Reading config file %s/config.yml", configpath) 142 | if err := viper.ReadInConfig(); err != nil { 143 | logger.Fatalf("Error reading config file %s/config.yml: %s", configpath, err) 144 | } 145 | if err = viper.Unmarshal(&configuration); err != nil { 146 | logger.Fatalf("Unable to decode config file into struct: %s", err) 147 | } 148 | // Read in credentials file 149 | credentialspath := filepath.Join(configpath, "credentials.yml") 150 | logger.Infof("Reading credentials file %s", credentialspath) 151 | 152 | if credfile, err = os.ReadFile(credentialspath); err != nil { 153 | logger.Fatalf("Unable to read credential file %s %v", credentialspath, err) 154 | } 155 | if err = yaml.Unmarshal(credfile, &credentialsConfig); err != nil { 156 | logger.Fatalf("Unable to parse YAML credential file %s %v", credentialspath, err) 157 | } 158 | 159 | logger.Debugf("Configuration: %+v\n", configuration) 160 | logger.Debugf("Viper AllSettings: %+v\n", viper.AllSettings()) 161 | 162 | // Ensure that session soft timeout <= hard timeout 163 | if configuration.SessionSoftTimeoutSeconds < 1 { 164 | logger.Fatalf("Invalid session soft timeout of %d, must be > 0", configuration.SessionSoftTimeoutSeconds) 165 | } else if configuration.SessionHardTimeoutSeconds < 1 { 166 | logger.Fatalf("Invalid session hard timeout of %d, must be > 0", configuration.SessionHardTimeoutSeconds) 167 | } else if configuration.SessionHardTimeoutSeconds < configuration.SessionSoftTimeoutSeconds { 168 | logger.Fatal("Invalid session hard timeout, must be > session soft timeout") 169 | } 170 | 171 | cookieSecrets = credentialsConfig.CookieSecrets 172 | if len(cookieSecrets) == 0 { 173 | logger.Warnf("You did not set any cookie_session_secrets in credentials.yml.") 174 | logger.Warnf("So it will be dynamic and your cookie sessions will not persist proxy restart.") 175 | logger.Warnf("Generate one using `-generate-secret` flag and add to credentials.yml.") 176 | } 177 | if len(cookieSecrets) > 0 && cookieSecrets[0] == "your-own-cookie-secret" { 178 | logger.Warnf("You did not set any valid cookie_session_secrets in credentials.yml.") 179 | logger.Fatalf("Generate one using `-generate-secret` flag and add to credentials.yml.") 180 | } 181 | for username, credential := range credentialsConfig.Credentials { 182 | unmarshaledUser, err := u.UnmarshalUser(credential) 183 | if err != nil { 184 | logger.Fatalf("Error unmarshalling user credential %s: %s", username, err) 185 | } 186 | if username != unmarshaledUser.Name { 187 | logger.Fatalf("Credentials for user %s are designated for another one %s", username, unmarshaledUser.Name) 188 | } 189 | users[username] = *unmarshaledUser 190 | if logrus.GetLevel() == logrus.DebugLevel { 191 | util.PrettyPrint(unmarshaledUser) 192 | } 193 | } 194 | 195 | // Print the effective config. 196 | fmt.Println() 197 | fmt.Printf("Relying Party Display Name: %s\n", configuration.RPDisplayName) 198 | fmt.Printf("Relying Party ID: %s\n", configuration.RPID) 199 | fmt.Printf("Relying Party Origins: %v\n", configuration.RPOrigins) 200 | fmt.Printf("Test Mode: %v\n", configuration.TestMode) 201 | fmt.Printf("Server Address: %s\n", configuration.ServerAddress) 202 | fmt.Printf("Server Port: %s\n", configuration.ServerPort) 203 | fmt.Printf("Session Soft Timeout: %d\n", configuration.SessionSoftTimeoutSeconds) 204 | fmt.Printf("Session Hard Timeout: %d\n", configuration.SessionHardTimeoutSeconds) 205 | fmt.Printf("Session Cookie Name: %s\n", configuration.SessionCookieName) 206 | fmt.Printf("User Cookie Name: %s\n", configuration.UserCookieName) 207 | fmt.Printf("Username Regex: %s\n", configuration.UsernameRegex) 208 | fmt.Printf("Cookie secure: %v\n", configuration.CookieSecure) 209 | fmt.Printf("Cookie domain: %s\n", configuration.CookieDomain) 210 | fmt.Printf("Cookie secrets: %d\n", len(cookieSecrets)) 211 | fmt.Printf("User credentials: %d\n", len(users)) 212 | fmt.Println() 213 | if configuration.TestMode { 214 | fmt.Printf("Warning!!! Test Mode enabled! This is not safe for production!\n\n") 215 | } 216 | 217 | // If list of relying party origins has been specified in configuration, 218 | // create one Webauthn config / Session store per origin, else origins will be dynamic. 219 | if len(configuration.RPOrigins) > 0 { 220 | for _, origin := range configuration.RPOrigins { 221 | if _, _, err := createWebAuthnClient(origin); err != nil { 222 | logger.Fatalf("Failed to create WebAuthn from config: %s", err) 223 | } 224 | } 225 | } else { 226 | dynamicOrigins = true 227 | } 228 | 229 | util.CookieSecure = configuration.CookieSecure 230 | util.CookieDomain = configuration.CookieDomain 231 | r := http.NewServeMux() 232 | fileServer := http.FileServer(http.Dir("./static")) 233 | r.Handle("/webauthn/static/", http.StripPrefix("/webauthn/static/", fileServer)) 234 | r.HandleFunc("/", HandleIndex) 235 | r.HandleFunc("/webauthn/login", HandleLogin) 236 | r.HandleFunc("/webauthn/login/get_credential_request_options", GetCredentialRequestOptions) 237 | r.HandleFunc("/webauthn/login/process_login_assertion", ProcessLoginAssertion) 238 | r.HandleFunc("/webauthn/register", HandleRegister) 239 | r.HandleFunc("/webauthn/register/get_credential_creation_options", GetCredentialCreationOptions) 240 | r.HandleFunc("/webauthn/register/process_registration_attestation", ProcessRegistrationAttestation) 241 | r.HandleFunc("/webauthn/auth", HandleAuth) 242 | r.HandleFunc("/webauthn/verify", HandleVerify) 243 | r.HandleFunc("/webauthn/logout", HandleLogout) 244 | 245 | listenAddress := fmt.Sprintf("%s:%s", configuration.ServerAddress, configuration.ServerPort) 246 | logger.Infof("Starting server at %s", listenAddress) 247 | logger.Fatal(http.ListenAndServe(listenAddress, r)) 248 | } 249 | 250 | // Root page 251 | func HandleIndex(w http.ResponseWriter, r *http.Request) { 252 | http.Redirect(w, r, "/webauthn/login", http.StatusTemporaryRedirect) 253 | } 254 | 255 | // /webauthn/auth - Check if user has an authenticated session 256 | // This endpoint can be used for internal nginx checks. 257 | // Also this endpoint prolongs the user session by soft limit interval. 258 | func HandleAuth(w http.ResponseWriter, r *http.Request) { 259 | _, sessionStore, err := checkOrigin(r) 260 | if err != nil { 261 | logger.Errorf("Error validating origin: %s", err) 262 | util.JSONResponse(w, authError, http.StatusBadRequest) 263 | return 264 | } 265 | 266 | session, err := sessionStore.Get(r, configuration.SessionCookieName) 267 | if err != nil { 268 | logger.Errorf("Error getting session from session store during user auth handler: %s", err) 269 | util.JSONResponse(w, authError, http.StatusInternalServerError) 270 | return 271 | } 272 | 273 | if auth, ok := session.Values["authenticated"].(bool); !ok || !auth { 274 | util.JSONResponse(w, authError, http.StatusUnauthorized) 275 | return 276 | } 277 | username := session.Values["authenticated_user"].(string) 278 | if time.Now().Unix()-session.Values["authenticated_time"].(int64) >= int64(configuration.SessionHardTimeoutSeconds) { 279 | // Session has exceeded the hard limit 280 | logger.Debugf("Expiring user %s session expired by hard limit", username) 281 | util.ExpireWebauthnSession(session, r, w) 282 | util.JSONResponse(w, authError, http.StatusUnauthorized) 283 | return 284 | } 285 | userIP := session.Values["authenticated_ip"].(string) 286 | if userIP != util.GetUserIP(r) { 287 | // User IP mismatches, let use to re-login 288 | logger.Debugf("Invalidating user %s session coming from %s while session was created from %s", username, util.GetUserIP(r), userIP) 289 | util.ExpireWebauthnSession(session, r, w) 290 | util.JSONResponse(w, authError, http.StatusUnauthorized) 291 | return 292 | } 293 | 294 | // Update the session to reset the soft timeout 295 | session.Save(r, w) 296 | w.Header().Set(AuthenticatedUsernameHeader, username) 297 | util.JSONResponse(w, WebAuthnMessage{Message: "OK"}, http.StatusOK) 298 | } 299 | 300 | // /webauthn/login - Show authenticated page or serve up login page 301 | func HandleLogin(w http.ResponseWriter, r *http.Request) { 302 | _, sessionStore, err := checkOrigin(r) 303 | if err != nil { 304 | logger.Errorf("Error validating origin: %s", err) 305 | util.JSONResponse(w, loginError, http.StatusBadRequest) 306 | return 307 | } 308 | 309 | session, err := sessionStore.Get(r, configuration.SessionCookieName) 310 | if err != nil { 311 | logger.Errorf("Error getting session from session store during login handler: %s", err) 312 | util.JSONResponse(w, loginError, http.StatusInternalServerError) 313 | return 314 | } 315 | 316 | // Prevents html caching because this page serves two different pages. 317 | w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0") 318 | if auth, ok := session.Values["authenticated"].(bool); !ok || !auth { 319 | content, err := os.ReadFile(filepath.Join(staticPath, "login.html")) 320 | if err != nil { 321 | util.JSONResponse(w, loginError, http.StatusNotFound) 322 | return 323 | } 324 | content = []byte(strings.Replace(string(content), configuration.UserCookieName, configuration.UserCookieName, 1)) 325 | reader := bytes.NewReader(content) 326 | http.ServeContent(w, r, "", time.Time{}, reader) 327 | return 328 | } 329 | 330 | if redirectUrl := r.URL.Query().Get("redirect_url"); redirectUrl != "" { 331 | http.Redirect(w, r, redirectUrl, http.StatusTemporaryRedirect) 332 | } else { 333 | http.ServeFile(w, r, filepath.Join(staticPath, "authenticated.html")) 334 | } 335 | } 336 | 337 | // /webauthn/logout - Logout page 338 | func HandleLogout(w http.ResponseWriter, r *http.Request) { 339 | _, sessionStore, err := checkOrigin(r) 340 | if err != nil { 341 | logger.Errorf("Error validating origin: %s", err) 342 | util.JSONResponse(w, authError, http.StatusBadRequest) 343 | return 344 | } 345 | 346 | session, err := sessionStore.Get(r, configuration.SessionCookieName) 347 | if err == nil { 348 | util.ExpireWebauthnSession(session, r, w) 349 | } 350 | http.Redirect(w, r, "/webauthn/login", http.StatusTemporaryRedirect) 351 | } 352 | 353 | // /webauthn/verify - one-time verification if user has recently authenticated, useful as 2FA check. 354 | func HandleVerify(w http.ResponseWriter, r *http.Request) { 355 | _, _, err := checkOrigin(r) 356 | if err != nil { 357 | logger.Errorf("Error validating origin: %s", err) 358 | util.JSONResponse(w, authError, http.StatusBadRequest) 359 | return 360 | } 361 | 362 | username := r.URL.Query().Get("username") 363 | userIP := r.URL.Query().Get("ip") 364 | if data, exists := loginVerifications[username]; exists { 365 | // Check whether this is whithin last 5 min. 366 | if data.LoginTime.Add(loginVerificationInterval).Before(time.Now()) { 367 | delete(loginVerifications, username) 368 | util.JSONResponse(w, authError, http.StatusUnauthorized) 369 | return 370 | } 371 | if data.IPAddr == userIP { 372 | // Check once and delete 373 | delete(loginVerifications, username) 374 | logger.Infof("User %s verified successfully from %s", username, userIP) 375 | util.JSONResponse(w, WebAuthnMessage{Message: "OK"}, http.StatusOK) 376 | return 377 | } else { 378 | logger.Warnf("User %s failed verification: auth IP %s, validating IP %s", username, data.IPAddr, userIP) 379 | } 380 | } 381 | util.JSONResponse(w, authError, http.StatusUnauthorized) 382 | } 383 | 384 | // /webauthn/register - Serve up registration page 385 | func HandleRegister(w http.ResponseWriter, r *http.Request) { 386 | http.ServeFile(w, r, filepath.Join(staticPath, "register.html")) 387 | } 388 | 389 | /* 390 | /webauthn/login/get_credential_request_options - 391 | Step 1 of the login process, get credential request options for the user 392 | */ 393 | func GetCredentialRequestOptions(w http.ResponseWriter, r *http.Request) { 394 | webAuthn, sessionStore, err := checkOrigin(r) 395 | if err != nil { 396 | logger.Errorf("Error validating origin: %s", err) 397 | util.JSONResponse(w, loginError, http.StatusBadRequest) 398 | return 399 | } 400 | 401 | username, err := util.GetUsername(r, configuration.UsernameRegex) 402 | if err != nil { 403 | logger.Errorf("Error getting username: %s", err) 404 | util.JSONResponse(w, loginError, http.StatusBadRequest) 405 | return 406 | } 407 | 408 | user, exists := users[username] 409 | if !exists { 410 | logger.Warnf("User %s does not exist", username) 411 | util.JSONResponse(w, loginError, http.StatusBadRequest) 412 | return 413 | } 414 | 415 | // Begin the login process 416 | options, sessionData, err := webAuthn.BeginLogin(user) 417 | if err != nil { 418 | logger.Errorf("Error beginning the login process: %s", err) 419 | util.JSONResponse(w, loginError, http.StatusInternalServerError) 420 | return 421 | } 422 | 423 | // Store Webauthn session data 424 | session, err := sessionStore.Get(r, configuration.SessionCookieName) 425 | if err != nil { 426 | logger.Errorf("Error getting session from session store during login: %s", err) 427 | util.JSONResponse(w, loginError, http.StatusInternalServerError) 428 | return 429 | } 430 | 431 | err = util.SaveWebauthnSession(session, "authentication", sessionData, r, w) 432 | if err != nil { 433 | logger.Errorf("Error saving Webauthn session during login: %s", err) 434 | util.JSONResponse(w, loginError, http.StatusInternalServerError) 435 | return 436 | } 437 | 438 | util.JSONResponse(w, options, http.StatusOK) 439 | } 440 | 441 | /* 442 | /webauthn/login/process_login_assertion - 443 | Step 2 of the login process, process the assertion from the client authenticator 444 | */ 445 | func ProcessLoginAssertion(w http.ResponseWriter, r *http.Request) { 446 | webAuthn, sessionStore, err := checkOrigin(r) 447 | if err != nil { 448 | logger.Errorf("Error validating origin: %s", err) 449 | util.JSONResponse(w, loginError, http.StatusBadRequest) 450 | return 451 | } 452 | 453 | username, err := util.GetUsername(r, configuration.UsernameRegex) 454 | if err != nil { 455 | logger.Errorf("Error getting username: %s", err) 456 | util.JSONResponse(w, loginError, http.StatusBadRequest) 457 | return 458 | } 459 | 460 | user, exists := users[username] 461 | if !exists { 462 | logger.Errorf("User %s does not exist", username) 463 | util.JSONResponse(w, loginError, http.StatusBadRequest) 464 | return 465 | } 466 | 467 | // Load the session data 468 | session, err := sessionStore.Get(r, configuration.SessionCookieName) 469 | if err != nil { 470 | logger.Errorf("Error getting session from session store during login: %s", err) 471 | util.JSONResponse(w, loginError, http.StatusInternalServerError) 472 | return 473 | } 474 | 475 | sessionData, err := util.FetchWebauthnSession(session, "authentication", r) 476 | if err != nil { 477 | logger.Errorf("Error getting Webauthn session during login: %s", err) 478 | util.JSONResponse(w, loginError, http.StatusInternalServerError) 479 | return 480 | } 481 | 482 | cred, err := webAuthn.FinishLogin(user, sessionData, r) 483 | if err != nil { 484 | logger.Errorf("Error finishing Webauthn login: %s", err) 485 | util.JSONResponse(w, loginError, http.StatusInternalServerError) 486 | return 487 | } 488 | 489 | // Check for cloned authenticators 490 | if cred.Authenticator.CloneWarning { 491 | logger.Errorf("Error. Authenticator for %s appears to be cloned, failing login", username) 492 | util.JSONResponse(w, loginError, http.StatusBadRequest) 493 | return 494 | } 495 | 496 | // Increment sign counter on user to help avoid clones 497 | if userCredential, err := user.CredentialById(cred.ID); err != nil { 498 | logger.Errorf("Error incrementing sign counter on user authenticator: %s", err) 499 | util.JSONResponse(w, loginError, http.StatusBadRequest) 500 | return 501 | } else { 502 | userCredential.Authenticator.UpdateCounter(cred.Authenticator.SignCount) 503 | } 504 | 505 | // Set user as authenticated 506 | userIP := util.GetUserIP(r) 507 | loginVerifications[username] = &LoginVerification{IPAddr: userIP, LoginTime: time.Now()} 508 | // session cookie 509 | session.Values["authenticated"] = true 510 | session.Values["authenticated_user"] = username 511 | session.Values["authenticated_time"] = time.Now().Unix() 512 | session.Values["authenticated_ip"] = userIP 513 | session.Save(r, w) 514 | // username cookie 515 | ck := http.Cookie{ 516 | Name: configuration.UserCookieName, 517 | Domain: configuration.CookieDomain, 518 | Path: "/", 519 | Value: username, 520 | Expires: time.Now().AddDate(1, 0, 0), // 1 year 521 | Secure: configuration.CookieSecure, 522 | } 523 | http.SetCookie(w, &ck) 524 | logger.Infof("User %s authenticated successfully from %s", username, userIP) 525 | util.JSONResponse(w, WebAuthnMessage{Message: "Authentication Successful"}, http.StatusOK) 526 | } 527 | 528 | /* 529 | /webauthn/register/get_credential_creation_options - 530 | Step 1 of the registration process, get credential creation options 531 | */ 532 | func GetCredentialCreationOptions(w http.ResponseWriter, r *http.Request) { 533 | webAuthn, sessionStore, err := checkOrigin(r) 534 | if err != nil { 535 | logger.Errorf("Error validating origin: %s", err) 536 | util.JSONResponse(w, registrationError, http.StatusBadRequest) 537 | return 538 | } 539 | 540 | username, err := util.GetUsername(r, configuration.UsernameRegex) 541 | if err != nil { 542 | logger.Errorf("Error getting username: %s", err) 543 | util.JSONResponse(w, registrationError, http.StatusBadRequest) 544 | return 545 | } 546 | 547 | // We allow a user to register multiple time with different authenticators. 548 | // First check if they are an existing user 549 | user, exists := users[username] 550 | if !exists { 551 | // Not found, see if they have registered previously 552 | if user, exists = registrations[username]; !exists { 553 | // Create a new user 554 | user = *u.NewUser(username) 555 | registrations[username] = user 556 | } 557 | } 558 | 559 | // Generate PublicKeyCredentialCreationOptions, session data} 560 | options, sessionData, err := webAuthn.BeginRegistration(user, user.UserRegistrationOptions) 561 | 562 | if err != nil { 563 | logger.Errorf("Error beginning Webauthn registration: %s", err) 564 | util.JSONResponse(w, registrationError, http.StatusInternalServerError) 565 | return 566 | } 567 | 568 | // Store session data as marshaled JSON 569 | session, err := sessionStore.Get(r, configuration.SessionCookieName) 570 | if err != nil { 571 | logger.Errorf("Error getting session from session store during registration: %s", err) 572 | util.JSONResponse(w, registrationError, http.StatusInternalServerError) 573 | return 574 | } 575 | 576 | if err = util.SaveWebauthnSession(session, "registration", sessionData, r, w); err != nil { 577 | logger.Errorf("Error saving Webauthn session during registration: %s", err) 578 | util.JSONResponse(w, registrationError, http.StatusInternalServerError) 579 | return 580 | } 581 | 582 | util.JSONResponse(w, options, http.StatusOK) 583 | } 584 | 585 | /* 586 | /webauthn/register/process_registration_attestation - 587 | Step 2 of the registration process, process the attestation (new credential) from the client authenticator 588 | */ 589 | func ProcessRegistrationAttestation(w http.ResponseWriter, r *http.Request) { 590 | webAuthn, sessionStore, err := checkOrigin(r) 591 | if err != nil { 592 | logger.Errorf("Error validating origin: %s", err) 593 | util.JSONResponse(w, registrationError, http.StatusBadRequest) 594 | return 595 | } 596 | 597 | username, err := util.GetUsername(r, configuration.UsernameRegex) 598 | if err != nil { 599 | logger.Errorf("Error getting username: %s", err) 600 | util.JSONResponse(w, registrationError, http.StatusBadRequest) 601 | return 602 | } 603 | 604 | // First check if they are an existing user 605 | user, exists := users[username] 606 | if !exists { 607 | // Not found, check the registrants pool 608 | if user, exists = registrations[username]; !exists { 609 | // Somethings wrong here. We made it here without the registrant going 610 | // through GetCredentialCreationOptions. Fail this request. 611 | logger.Errorf("Registrant %s skipped GetCredentialCreationOptions step, failing registration", username) 612 | util.JSONResponse(w, registrationError, http.StatusBadRequest) 613 | return 614 | } 615 | } 616 | 617 | // Load the session data 618 | session, err := sessionStore.Get(r, configuration.SessionCookieName) 619 | if err != nil { 620 | logger.Errorf("Error getting session from session store during registration: %s", err) 621 | util.JSONResponse(w, registrationError, http.StatusInternalServerError) 622 | return 623 | } 624 | 625 | sessionData, err := util.FetchWebauthnSession(session, "registration", r) 626 | if err != nil { 627 | logger.Errorf("Error getting Webauthn session during registration: %s", err) 628 | util.JSONResponse(w, registrationError, http.StatusInternalServerError) 629 | return 630 | } 631 | 632 | credential, err := webAuthn.FinishRegistration(user, sessionData, r) 633 | if err != nil { 634 | logger.Errorf("Error finishing Webauthn registration: %s", err) 635 | util.JSONResponse(w, registrationError, http.StatusInternalServerError) 636 | return 637 | } 638 | 639 | // Check that the credential doesn't belong to another user or registrant 640 | for _, u := range users { 641 | for _, c := range u.Credentials { 642 | if bytes.Equal(c.ID, credential.ID) { 643 | logger.Errorf("Error registering credential for user %s, matching credential ID with user %s", username, u.Name) 644 | util.JSONResponse(w, registrationError, http.StatusBadRequest) 645 | return 646 | } 647 | } 648 | } 649 | for _, r := range registrations { 650 | for _, c := range r.Credentials { 651 | if bytes.Equal(c.ID, credential.ID) { 652 | logger.Errorf("Error registering credential for user %s, matching credential ID with registrant %s", username, r.Name) 653 | util.JSONResponse(w, registrationError, http.StatusBadRequest) 654 | return 655 | } 656 | } 657 | } 658 | 659 | // Add the credential to the user 660 | user.AddCredential(*credential) 661 | 662 | // Note: enabling this can be risky as it allows anyone to add themselves to the proxy. 663 | // Only enable test mode during testing! 664 | if configuration.TestMode { 665 | users[username] = user 666 | delete(registrations, username) 667 | } 668 | 669 | // Marshal the user so it can be added to the credentials file 670 | marshaledUser, err := user.Marshal() 671 | if err != nil { 672 | logger.Errorf("Error marshalling user object: %s", err) 673 | util.JSONResponse(w, registrationError, http.StatusInternalServerError) 674 | return 675 | } 676 | 677 | userCredText := fmt.Sprintf("%s: %s", username, marshaledUser) 678 | successMessage := RegistrationSuccess{ 679 | Message: "Registration Successful. Please share the values below with your system administrator so they can add you!", 680 | Data: userCredText, 681 | } 682 | logger.Infof("New user registration: %s", userCredText) 683 | util.JSONResponse(w, successMessage, http.StatusOK) 684 | } 685 | 686 | // Check that the origin is in our configuration or we're allowing dynamic origins 687 | func checkOrigin(r *http.Request) (*webauthn.WebAuthn, *sessions.CookieStore, error) { 688 | u, err := url.Parse(r.URL.RequestURI()) 689 | if err != nil { 690 | return nil, nil, fmt.Errorf("RPOrigin not valid URL: %+v", err) 691 | } 692 | 693 | // Try to determine the scheme, falling back to https 694 | var scheme string 695 | if u.Scheme != "" { 696 | scheme = u.Scheme 697 | } else if r.Header.Get("X-Forwarded-Proto") != "" { 698 | scheme = r.Header.Get("X-Forwarded-Proto") 699 | } else if r.TLS != nil { 700 | scheme = "https" 701 | } else { 702 | scheme = "http" 703 | } 704 | origin := fmt.Sprintf("%s://%s", scheme, r.Host) 705 | 706 | if webAuthn, exists := webAuthns[origin]; exists { 707 | sessionStore := sessionStores[origin] 708 | return webAuthn, sessionStore, nil 709 | } 710 | 711 | if !dynamicOrigins { 712 | return nil, nil, fmt.Errorf("request origin not valid: %s", origin) 713 | } else { 714 | logger.Infof("Adding new dynamic origin: %s", origin) 715 | webAuthn, sessionStore, err := createWebAuthnClient(origin) 716 | return webAuthn, sessionStore, err 717 | } 718 | } 719 | 720 | // createWebAuthnClient add webauthn client and session store per origin 721 | func createWebAuthnClient(origin string) (*webauthn.WebAuthn, *sessions.CookieStore, error) { 722 | webAuthn, err := webauthn.New(&webauthn.Config{ 723 | RPDisplayName: configuration.RPDisplayName, // Relying party display name 724 | RPID: configuration.RPID, // Relying party ID 725 | RPOrigins: []string{origin}, // Relying party origin 726 | }) 727 | if err != nil { 728 | return nil, nil, fmt.Errorf("failed to create WebAuthn for origin: %s", origin) 729 | } 730 | webAuthns[origin] = webAuthn 731 | 732 | var stringKeys []string 733 | var byteKeyPairs [][]byte 734 | if len(cookieSecrets) == 0 { 735 | stringKeys = []string{util.GenChallenge()} 736 | } else { 737 | stringKeys = cookieSecrets 738 | } 739 | // Each keypair consists of auth key and enc key. 740 | // If auth or enc key is changed all users will have to re-login. 741 | // enc key is optional and should be up to 32 bytes! 742 | // Otherwise it will whether fail with unclear error on login/register 743 | // or if you are lucky complain about the length. Not using enc key (nil). 744 | for _, s := range stringKeys { 745 | byteKeyPairs = append(byteKeyPairs, []byte(s), nil) 746 | } 747 | var sessionStore = sessions.NewCookieStore(byteKeyPairs...) 748 | sessionStore.Options = &sessions.Options{ 749 | Domain: configuration.CookieDomain, 750 | Path: "/", 751 | MaxAge: configuration.SessionSoftTimeoutSeconds, 752 | HttpOnly: true, 753 | Secure: configuration.CookieSecure, 754 | } 755 | sessionStores[origin] = sessionStore 756 | return webAuthn, sessionStore, nil 757 | } 758 | -------------------------------------------------------------------------------- /static/authenticated.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WebAuthn Proxy Authenticated 5 | 6 | 7 | 8 | 9 |
10 | 11 |
Successfully Authenticated
12 |
13 | Logout 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /static/clipboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/webauthn_proxy/45bc92dffc24debc7e7c7873586676870efcee9f/static/clipboard.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/webauthn_proxy/45bc92dffc24debc7e7c7873586676870efcee9f/static/favicon.ico -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | Nothing to see here. 2 | -------------------------------------------------------------------------------- /static/jquery-3.6.3.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v3.6.3 | (c) OpenJS Foundation and other contributors | jquery.org/license */ 2 | !function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),v={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},S=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||S).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.3",E=function(e,t){return new E.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,S)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=E)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{if(d.cssSupportsSelector&&!CSS.supports("selector(:is("+c+"))"))throw new Error;return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===E&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[E]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,S=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.cssSupportsSelector=ce(function(){return CSS.supports("selector(*)")&&C.querySelectorAll(":is(:jqfake)")&&!CSS.supports("selector(:is(*,:jqfake))")}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=E,!C.getElementsByName||!C.getElementsByName(E).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&S){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&S){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&S)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+E+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+E+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),d.cssSupportsSelector||y.push(":has"),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType&&e.documentElement||e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&S&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?E.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?E.grep(e,function(e){return e===n!==r}):"string"!=typeof n?E.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(E.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof E?t[0]:t,E.merge(this,E.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:S,!0)),N.test(r[1])&&E.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=S.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(E):E.makeArray(e,this)}).prototype=E.fn,D=E(S);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}E.fn.extend({has:function(e){var t=E(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=S.createDocumentFragment().appendChild(S.createElement("div")),(fe=S.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?E.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&E(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),S.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;E.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||E.expando+"_"+Ct.guid++;return this[e]=!0,e}}),E.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||E.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?E(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=S.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),E.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=S.implementation.createHTMLDocument("")).createElement("base")).href=S.location.href,t.head.appendChild(r)):t=S),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&E(o).remove(),E.merge([],i.childNodes)));var r,i,o},E.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(E.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},E.expr.pseudos.animated=function(t){return E.grep(E.timers,function(e){return t===e.elem}).length},E.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=E.css(e,"position"),c=E(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=E.css(e,"top"),u=E.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,E.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},E.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){E.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===E.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===E.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=E(e).offset()).top+=E.css(e,"borderTopWidth",!0),i.left+=E.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-E.css(r,"marginTop",!0),left:t.left-i.left-E.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===E.css(e,"position"))e=e.offsetParent;return e||re})}}),E.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;E.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),E.each(["top","left"],function(e,n){E.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?E(e).position()[n]+"px":t})}),E.each({Height:"height",Width:"width"},function(a,s){E.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){E.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?E.css(e,t,i):E.style(e,t,n,i)},s,n?e:void 0,n)}})}),E.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){E.fn[t]=function(e){return this.on(t,e)}}),E.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),E.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){E.fn[n]=function(e,t){return 0 2 | 3 | 4 | WebAuthn Proxy Login 5 | 6 | 7 | 8 | 9 | 63 | 64 | 65 |
66 | 67 |
68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 |
Authenticate:
77 |
78 | New User? Register 79 |
80 | 81 | 82 | -------------------------------------------------------------------------------- /static/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WebAuthn Proxy Register 5 | 6 | 7 | 8 | 9 | 42 | 43 | 44 |
45 | 46 |
47 |
48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 |
Username:
56 |
57 | 58 | 59 | 60 | 61 |
62 | Already registered? Login 63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | let errorMessage = message => { 2 | $('#errorMessages').text(message); 3 | $('#successMessages').text(''); 4 | }; 5 | 6 | let successMessage = message => { 7 | $('#errorMessages').text(''); 8 | $('#successMessages').text(message); 9 | }; 10 | 11 | let preformattedMessage = message => { 12 | $('#preformattedMessages').text(message); 13 | }; 14 | 15 | let browserCheck = () => { 16 | if (!window.PublicKeyCredential) { 17 | errorMessage('This browser does not support WebAuthn :('); 18 | return false; 19 | } 20 | 21 | return true; 22 | }; 23 | 24 | // base64url > base64 > Uint8Array > ArrayBuffer 25 | let bufferDecode = value => Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)) 26 | .buffer; 27 | 28 | // ArrayBuffer > Uint8Array > base64 > base64url 29 | let bufferEncode = value => btoa(String.fromCharCode.apply(null, new Uint8Array(value))) 30 | .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 31 | 32 | let formatFinishRegParams = cred => JSON.stringify({ 33 | id: cred.id, 34 | rawId: bufferEncode(cred.rawId), 35 | type: cred.type, 36 | response: { 37 | attestationObject: bufferEncode(cred.response.attestationObject), 38 | clientDataJSON: bufferEncode(cred.response.clientDataJSON), 39 | }, 40 | }); 41 | 42 | let formatFinishLoginParams = assertion => JSON.stringify({ 43 | id: assertion.id, 44 | rawId: bufferEncode(assertion.rawId), 45 | type: assertion.type, 46 | response: { 47 | authenticatorData: bufferEncode(assertion.response.authenticatorData), 48 | clientDataJSON: bufferEncode(assertion.response.clientDataJSON), 49 | signature: bufferEncode(assertion.response.signature), 50 | userHandle: bufferEncode(assertion.response.userHandle), 51 | } 52 | }); 53 | 54 | let registerUser = () => { 55 | let username = $('#username').val(); 56 | 57 | if (username === '') { 58 | errorMessage('Please enter a valid username'); 59 | return; 60 | } 61 | 62 | $.get( 63 | '/webauthn/register/get_credential_creation_options?username=' + encodeURIComponent(username), 64 | null, 65 | data => data, 66 | 'json') 67 | .then(credCreateOptions => { 68 | credCreateOptions.publicKey.challenge = bufferDecode(credCreateOptions.publicKey.challenge); 69 | credCreateOptions.publicKey.user.id = bufferDecode(credCreateOptions.publicKey.user.id); 70 | if (credCreateOptions.publicKey.excludeCredentials) { 71 | for (cred of credCreateOptions.publicKey.excludeCredentials) { 72 | cred.id = bufferDecode(cred.id); 73 | } 74 | } 75 | 76 | return navigator.credentials.create({ 77 | publicKey: credCreateOptions.publicKey 78 | }); 79 | }) 80 | .then(cred => $.post( 81 | '/webauthn/register/process_registration_attestation?username=' + encodeURIComponent(username), 82 | formatFinishRegParams(cred), 83 | data => data, 84 | 'json')) 85 | .then(success => { 86 | successMessage(success.Message); 87 | preformattedMessage(success.Data); 88 | }) 89 | .catch(error => { 90 | if(error.hasOwnProperty("responseJSON")){ 91 | errorMessage(error.responseJSON.Message); 92 | } else { 93 | errorMessage(error); 94 | } 95 | }); 96 | }; 97 | 98 | let authenticateUser = () => { 99 | let username = $('#username').val(); 100 | if (username === '') { 101 | errorMessage('Please enter a valid username'); 102 | return; 103 | } 104 | 105 | $.get( 106 | '/webauthn/login/get_credential_request_options?username=' + encodeURIComponent(username), 107 | null, 108 | data => data, 109 | 'json') 110 | .then(credRequestOptions => { 111 | credRequestOptions.publicKey.challenge = bufferDecode(credRequestOptions.publicKey.challenge); 112 | credRequestOptions.publicKey.allowCredentials.forEach(listItem => { 113 | listItem.id = bufferDecode(listItem.id) 114 | }); 115 | 116 | return navigator.credentials.get({ 117 | publicKey: credRequestOptions.publicKey 118 | }); 119 | }) 120 | .then(assertion => $.post( 121 | '/webauthn/login/process_login_assertion?username=' + encodeURIComponent(username), 122 | formatFinishLoginParams(assertion), 123 | data => data, 124 | 'json')) 125 | .then(success => { 126 | successMessage(success.Message); 127 | window.location.reload(); 128 | }) 129 | .catch(error => { 130 | if(error.hasOwnProperty("responseJSON")){ 131 | errorMessage(error.responseJSON.Message); 132 | } else { 133 | errorMessage(error); 134 | } 135 | }); 136 | }; 137 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Open Sans", sans-serif; 3 | font-size: 1.125rem; 4 | color: #888; 5 | font-weight: 400; 6 | } 7 | 8 | a { 9 | color: blue; 10 | text-decoration: underline; 11 | cursor: pointer; 12 | } 13 | 14 | button { 15 | cursor: pointer; 16 | } 17 | 18 | #form { 19 | text-align: center; 20 | } 21 | 22 | #form table { 23 | margin-left: auto; 24 | margin-right: auto; 25 | } 26 | 27 | #successMessages { 28 | color: green; 29 | } 30 | 31 | #errorMessages { 32 | color: red; 33 | } 34 | 35 | #preformattedMessages { 36 | font-family: monospace; 37 | /* white-space: pre;*/ 38 | } 39 | 40 | #copyButton { 41 | height: 30px; 42 | width: 35px; 43 | vertical-align: top; 44 | } 45 | -------------------------------------------------------------------------------- /static/title-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/webauthn_proxy/45bc92dffc24debc7e7c7873586676870efcee9f/static/title-image.png -------------------------------------------------------------------------------- /user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | b64 "encoding/base64" 7 | "encoding/binary" 8 | "encoding/json" 9 | "fmt" 10 | 11 | "github.com/go-webauthn/webauthn/protocol" 12 | "github.com/go-webauthn/webauthn/webauthn" 13 | ) 14 | 15 | type User struct { 16 | ID uint64 17 | Name string 18 | DisplayName string 19 | Credentials []webauthn.Credential 20 | } 21 | 22 | func NewUser(name string) *User { 23 | user := &User{} 24 | user.ID = randomUint64() 25 | user.Name = name 26 | user.DisplayName = name 27 | 28 | return user 29 | } 30 | 31 | // Return user credential by ID 32 | func (u User) CredentialById(id []byte) (*webauthn.Credential, error) { 33 | var result *webauthn.Credential 34 | for _, cred := range u.Credentials { 35 | if bytes.Compare(cred.ID, id) == 0 { 36 | result = &cred 37 | break 38 | } 39 | } 40 | 41 | if result == nil { 42 | return nil, fmt.Errorf("Failed to find credential ID %s for User %s", id, u.Name) 43 | } 44 | 45 | return result, nil 46 | } 47 | 48 | func (u User) Marshal() (string, error) { 49 | marshaledUser, err := json.Marshal(u) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | encodedUser := b64.StdEncoding.EncodeToString([]byte(marshaledUser)) 55 | 56 | return encodedUser, nil 57 | } 58 | 59 | func UnmarshalUser(user string) (*User, error) { 60 | decodedUser, err := b64.StdEncoding.DecodeString(user) 61 | if err != nil { 62 | return NewUser("error"), err 63 | } 64 | 65 | unmarshaledUser := &User{} 66 | if err = json.Unmarshal(decodedUser, &unmarshaledUser); err != nil { 67 | return NewUser("error"), err 68 | } 69 | 70 | return unmarshaledUser, nil 71 | } 72 | 73 | // Set user registration options, such as excluding registered credentials 74 | func (u User) UserRegistrationOptions(credCreateOptions *protocol.PublicKeyCredentialCreationOptions) { 75 | credExcludeList := []protocol.CredentialDescriptor{} 76 | for _, cred := range u.Credentials { 77 | descriptor := protocol.CredentialDescriptor{ 78 | Type: protocol.PublicKeyCredentialType, 79 | CredentialID: cred.ID, 80 | } 81 | credExcludeList = append(credExcludeList, descriptor) 82 | } 83 | 84 | credCreateOptions.CredentialExcludeList = credExcludeList 85 | } 86 | 87 | func (u User) WebAuthnID() []byte { 88 | buf := make([]byte, binary.MaxVarintLen64) 89 | binary.PutUvarint(buf, uint64(u.ID)) 90 | return buf 91 | } 92 | 93 | // WebAuthnIcon is not (yet) implemented 94 | func (u User) WebAuthnIcon() string { 95 | return "" 96 | } 97 | 98 | func (u User) WebAuthnName() string { 99 | return u.Name 100 | } 101 | 102 | func (u User) WebAuthnDisplayName() string { 103 | return u.DisplayName 104 | } 105 | 106 | func (u *User) AddCredential(cred webauthn.Credential) { 107 | u.Credentials = append(u.Credentials, cred) 108 | } 109 | 110 | func (u User) WebAuthnCredentials() []webauthn.Credential { 111 | return u.Credentials 112 | } 113 | 114 | func randomUint64() uint64 { 115 | buf := make([]byte, 8) 116 | rand.Read(buf) 117 | return binary.LittleEndian.Uint64(buf) 118 | } 119 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/go-webauthn/webauthn/protocol" 14 | "github.com/go-webauthn/webauthn/webauthn" 15 | "github.com/gorilla/sessions" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | // Placeholder for cookieSecure and cookieDomain config values 20 | var CookieSecure bool = false 21 | var CookieDomain string = "" 22 | 23 | // Get "username" query param and validate against supplied regex 24 | func GetUsername(r *http.Request, regex string) (string, error) { 25 | username := r.URL.Query().Get("username") 26 | if username == "" { 27 | return "", fmt.Errorf("you must supply a username") 28 | } 29 | if matched, err := regexp.MatchString(regex, username); !matched || err != nil { 30 | return "", fmt.Errorf("you must supply a valid username") 31 | } 32 | return username, nil 33 | } 34 | 35 | // Marshal object to JSON and write response 36 | func JSONResponse(w http.ResponseWriter, d interface{}, c int) { 37 | dj, err := json.Marshal(d) 38 | if err != nil { 39 | http.Error(w, "Error creating JSON response", http.StatusInternalServerError) 40 | } 41 | w.Header().Set("Content-Type", "application/json") 42 | w.WriteHeader(c) 43 | fmt.Fprintf(w, "%s", dj) 44 | } 45 | 46 | // Fetch webauthn session data from session store 47 | func FetchWebauthnSession(session *sessions.Session, key string, r *http.Request) (webauthn.SessionData, error) { 48 | sessionData := webauthn.SessionData{} 49 | assertion, ok := session.Values[key].([]byte) 50 | if !ok { 51 | return sessionData, fmt.Errorf("error unmarshaling session data") 52 | } 53 | err := json.Unmarshal(assertion, &sessionData) 54 | if err != nil { 55 | return sessionData, err 56 | } 57 | // Delete the value from the session now that it's been read 58 | delete(session.Values, key) 59 | return sessionData, nil 60 | } 61 | 62 | // Save webauthn session data to session store 63 | func SaveWebauthnSession(session *sessions.Session, key string, sessionData *webauthn.SessionData, r *http.Request, w http.ResponseWriter) error { 64 | marshaledData, err := json.Marshal(sessionData) 65 | if err != nil { 66 | return err 67 | } 68 | session.Values[key] = marshaledData 69 | session.Save(r, w) 70 | return nil 71 | } 72 | 73 | // ExpireWebauthnSession invalidate session by expiring cookie 74 | func ExpireWebauthnSession(session *sessions.Session, r *http.Request, w http.ResponseWriter) { 75 | session.Options = &sessions.Options{ 76 | Domain: CookieDomain, 77 | Path: "/", 78 | MaxAge: -1, 79 | HttpOnly: true, 80 | Secure: CookieSecure, 81 | } 82 | session.Save(r, w) 83 | } 84 | 85 | // GetUserIP return user IP address 86 | func GetUserIP(r *http.Request) string { 87 | ip := r.Header.Get("X-Forwarded-For") 88 | if ip != "" { 89 | return ip 90 | } 91 | ip = r.Header.Get("X-Real-Ip") 92 | if ip != "" { 93 | return ip 94 | } 95 | return strings.Split(r.RemoteAddr, ":")[0] 96 | } 97 | 98 | // Generate crytographically secure challenge 99 | func GenChallenge() string { 100 | //call on the import DUO method 101 | challenge, err := protocol.CreateChallenge() 102 | if err != nil { 103 | panic("Failed to generate cryptographically secure challenge") 104 | } 105 | return base64.RawURLEncoding.EncodeToString(challenge) 106 | } 107 | 108 | func PrettyPrint(data interface{}) { 109 | var p []byte 110 | p, err := json.MarshalIndent(data, "", "\t") 111 | if err != nil { 112 | fmt.Println(err) 113 | return 114 | } 115 | fmt.Printf("%s \n", p) 116 | } 117 | 118 | // SetupLogging setup logger 119 | func SetupLogging(name, loggingLevel string) *logrus.Entry { 120 | if loggingLevel != "info" { 121 | if level, err := logrus.ParseLevel(loggingLevel); err == nil { 122 | logrus.SetLevel(level) 123 | } 124 | } 125 | logrus.SetFormatter(&logrus.TextFormatter{ 126 | TimestampFormat: time.RFC3339, 127 | FullTimestamp: true, 128 | }) 129 | // Output to stdout instead of the default stderr. 130 | logrus.SetOutput(os.Stdout) 131 | return logrus.WithFields(logrus.Fields{"logger": name}) 132 | } 133 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | version = "0.2" 5 | ) 6 | --------------------------------------------------------------------------------