├── .gitignore ├── .run ├── All Unit Tests.run.xml └── Run GoAuth2.run.xml ├── .travis.yml ├── LICENSE ├── README.md ├── TODO.md ├── authorization_code.go ├── authorization_code_commands.go ├── authorization_code_events.go ├── authorization_code_process_manager.go ├── authorization_code_refresh_tokens_projection.go ├── authorization_code_refresh_tokens_projection_test.go ├── build ├── Dockerfile └── README.md ├── client_application.go ├── client_application_command_authorization.go ├── client_application_commands.go ├── client_application_events.go ├── cmd └── goauth2 │ └── main.go ├── commands.go ├── docs ├── README.md └── images │ └── oauth2-event-model.jpg ├── events.go ├── go.mod ├── go.sum ├── goauth2.go ├── goauth2_test.go ├── goauth2test └── seeded_token_generator.go ├── helper_test.go ├── passwords.go ├── passwords_test.go ├── pkg └── securepass │ └── securepass.go ├── projection ├── client_applications.go ├── client_applications_test.go ├── email_to_userid.go ├── email_to_userid_test.go ├── users.go └── users_test.go ├── provider └── uuidtoken │ ├── uuid_token_generator.go │ └── uuid_token_generator_test.go ├── refresh_token.go ├── refresh_token_commands.go ├── refresh_token_events.go ├── refresh_token_process_manager.go ├── resource_owner.go ├── resource_owner_command_authorization.go ├── resource_owner_commands.go ├── resource_owner_events.go └── web ├── admin_web_app.go ├── functions.go ├── saved_events.go ├── static ├── css │ ├── foundation-6.5.3.min.css │ ├── foundation-icons.css │ ├── foundation-icons.woff │ └── site.css └── img │ ├── favicon.ico │ └── goauth2-logo-white-30x30.png ├── templates ├── admin │ ├── add-user.gohtml │ ├── list-client-applications.gohtml │ ├── list-users.gohtml │ └── login.gohtml ├── layout │ └── base.gohtml └── login.gohtml ├── web_app.go ├── web_app_private_test.go ├── web_app_session.go └── web_app_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *_gen.go 3 | statik/ 4 | -------------------------------------------------------------------------------- /.run/All Unit Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.run/Run GoAuth2.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.14.3" 5 | 6 | sudo: false 7 | 8 | install: 9 | - go mod download 10 | - go generate ./... 11 | - go vet ./... 12 | 13 | before_script: 14 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 15 | - chmod +x ./cc-test-reporter 16 | - ./cc-test-reporter before-build 17 | 18 | script: 19 | - go test -coverprofile c.out.tmp ./... 20 | - cat c.out.tmp | grep -v "_gen.go" > c.out 21 | - go tool cover -func c.out 22 | 23 | after_script: 24 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Inklabs LLC., Jamie Isaacs 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in 13 | the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go OAuth2 Server 2 | 3 | [![Build Status](https://travis-ci.org/inklabs/goauth2.svg?branch=master)](https://travis-ci.org/inklabs/goauth2) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/inklabs/goauth2)](https://goreportcard.com/report/github.com/inklabs/goauth2) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/7970cb8ab9408b433cde/test_coverage)](https://codeclimate.com/github/inklabs/goauth2/test_coverage) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/7970cb8ab9408b433cde/maintainability)](https://codeclimate.com/github/inklabs/goauth2/maintainability) 7 | [![GoDoc](https://godoc.org/github.com/inklabs/goauth2?status.svg)](https://godoc.org/github.com/inklabs/goauth2) 8 | [![Go Version](https://img.shields.io/github/go-mod/go-version/inklabs/goauth2.svg)](https://github.com/inklabs/goauth2/blob/master/go.mod) 9 | [![Release](https://img.shields.io/github/release/inklabs/goauth2.svg?include_prereleases&sort=semver)](https://github.com/inklabs/goauth2/releases/latest) 10 | [![License](https://img.shields.io/github/license/inklabs/goauth2.svg)](https://github.com/inklabs/goauth2/blob/master/LICENSE) 11 | 12 | An OAuth2 server in Go. This project uses an embedded [RangeDB](https://www.github.com/inklabs/rangedb) event store. 13 | 14 | ## Docs 15 | 16 | - [Docs](https://github.com/inklabs/goauth2/tree/master/docs) 17 | 18 | ## Docker 19 | 20 | ``` 21 | docker run -p 8080:8080 inklabs/goauth2 22 | ``` 23 | 24 | ## Client Credentials Grant 25 | 26 | * https://tools.ietf.org/html/rfc6749#section-4.4 27 | 28 | ``` 29 | +---------+ +---------------+ 30 | | | | | 31 | | |>--(A)- Client Authentication --->| Authorization | 32 | | Client | | Server | 33 | | |<--(B)---- Access Token ---------<| | 34 | | | | | 35 | +---------+ +---------------+ 36 | ``` 37 | 38 | ```shell script 39 | curl localhost:8080/token \ 40 | -u client_id_hash:client_secret_hash \ 41 | -d "grant_type=client_credentials" \ 42 | -d "scope=read_write" 43 | ``` 44 | 45 | ```json 46 | { 47 | "access_token": "d5f4985587ea46028c0946e4a240a9c1", 48 | "expires_at": 1574371565, 49 | "token_type": "Bearer", 50 | "scope": "read_write" 51 | } 52 | ``` 53 | 54 | ## Resource Owner Password Credentials 55 | 56 | * https://tools.ietf.org/html/rfc6749#section-4.3 57 | 58 | ``` 59 | +----------+ 60 | | Resource | 61 | | Owner | 62 | | | 63 | +----------+ 64 | v 65 | | Resource Owner 66 | (A) Password Credentials 67 | | 68 | v 69 | +---------+ +---------------+ 70 | | |>--(B)---- Resource Owner ------->| | 71 | | | Password Credentials | Authorization | 72 | | Client | | Server | 73 | | |<--(C)---- Access Token ---------<| | 74 | | | (w/ Optional Refresh Token) | | 75 | +---------+ +---------------+ 76 | ``` 77 | 78 | ```shell script 79 | curl localhost:8080/token \ 80 | -u client_id_hash:client_secret_hash \ 81 | -d "grant_type=password" \ 82 | -d "username=john@example.com" \ 83 | -d "password=Pass123!" \ 84 | -d "scope=read_write" 85 | ``` 86 | 87 | ```json 88 | { 89 | "access_token": "a3c5300be4d24e65a68176c7ba521c50", 90 | "expires_at": 1574371565, 91 | "token_type": "Bearer", 92 | "scope": "read_write", 93 | "refresh_token": "3a801b1fc3d847599b3d5719d82bca7b" 94 | } 95 | ``` 96 | 97 | ## Refresh Token 98 | 99 | * https://tools.ietf.org/html/rfc6749#section-1.5 100 | * https://tools.ietf.org/html/rfc6749#section-6 101 | 102 | ``` 103 | +--------+ +---------------+ 104 | | |--(A)------- Authorization Grant --------->| | 105 | | | | | 106 | | |<-(B)----------- Access Token -------------| | 107 | | | & Refresh Token | | 108 | | | | | 109 | | | +----------+ | | 110 | | |--(C)---- Access Token ---->| | | | 111 | | | | | | | 112 | | |<-(D)- Protected Resource --| Resource | | Authorization | 113 | | Client | | Server | | Server | 114 | | |--(E)---- Access Token ---->| | | | 115 | | | | | | | 116 | | |<-(F)- Invalid Token Error -| | | | 117 | | | +----------+ | | 118 | | | | | 119 | | |--(G)----------- Refresh Token ----------->| | 120 | | | | | 121 | | |<-(H)----------- Access Token -------------| | 122 | +--------+ & Optional Refresh Token +---------------+ 123 | ``` 124 | 125 | ```shell script 126 | curl localhost:8080/token \ 127 | -u client_id_hash:client_secret_hash \ 128 | -d "grant_type=refresh_token" \ 129 | -d "refresh_token=3a801b1fc3d847599b3d5719d82bca7b" 130 | ``` 131 | 132 | ```json 133 | { 134 | "access_token": "97ed11d0d399454eb5ab2cab8b29f600", 135 | "expires_at": 1574371565, 136 | "token_type": "Bearer", 137 | "scope": "read_write", 138 | "refresh_token": "b4c69a71124641739f6a83b786b332d3" 139 | } 140 | ``` 141 | 142 | ## Authorization Code 143 | 144 | * https://tools.ietf.org/html/rfc6749#section-4.1 145 | 146 | ``` 147 | +----------+ 148 | | Resource | 149 | | Owner | 150 | | | 151 | +----------+ 152 | ^ 153 | | 154 | (B) 155 | +----|-----+ Client Identifier +---------------+ 156 | | -+----(A)-- & Redirection URI ---->| | 157 | | User- | | Authorization | 158 | | Agent -+----(B)-- User authenticates --->| Server | 159 | | | | | 160 | | -+----(C)-- Authorization Code ---<| | 161 | +-|----|---+ +---------------+ 162 | | | ^ v 163 | (A) (C) | | 164 | | | | | 165 | ^ v | | 166 | +---------+ | | 167 | | |>---(D)-- Authorization Code ---------' | 168 | | Client | & Redirection URI | 169 | | | | 170 | | |<---(E)----- Access Token -------------------' 171 | +---------+ (w/ Optional Refresh Token) 172 | ``` 173 | 174 | ``` 175 | open http://localhost:8080/authorize?client_id=client_id_hash&redirect_uri=https%3A%2F%2Fexample.com%2Foauth2%2Fcallback&response_type=code&state=somestate&scope=read_write 176 | ``` 177 | 178 | 1. Login via the web form (john@example.com | Pass123!) 179 | 1. Click button to grant access 180 | 1. The authorization server redirects back to the redirection URI including an authorization code and any 181 | state provided by the client 182 | 183 | ``` 184 | https://example.com/oauth2/callback?code=36e2807ee1f94252ac2d9b1d3adf2ba2&state=somestate 185 | ``` 186 | 187 | ```shell script 188 | curl localhost:8080/token \ 189 | -u client_id_hash:client_secret_hash \ 190 | -d "grant_type=authorization_code" \ 191 | -d "code=36e2807ee1f94252ac2d9b1d3adf2ba2" \ 192 | -d "redirect_uri=https://example.com/oauth2/callback" 193 | ``` 194 | 195 | ```json 196 | { 197 | "access_token": "865382b944024b2394167d519fa80cba", 198 | "expires_at": 1574371565, 199 | "token_type": "Bearer", 200 | "scope": "read_write", 201 | "refresh_token": "48403032170e46e8af72b7cca1612b43" 202 | } 203 | ``` 204 | 205 | ## Implicit 206 | 207 | * http://tools.ietf.org/html/rfc6749#section-4.2 208 | 209 | ``` 210 | +----------+ 211 | | Resource | 212 | | Owner | 213 | | | 214 | +----------+ 215 | ^ 216 | | 217 | (B) 218 | +----|-----+ Client Identifier +---------------+ 219 | | -+----(A)-- & Redirection URI --->| | 220 | | User- | | Authorization | 221 | | Agent -|----(B)-- User authenticates -->| Server | 222 | | | | | 223 | | |<---(C)--- Redirection URI ----<| | 224 | | | with Access Token +---------------+ 225 | | | in Fragment 226 | | | +---------------+ 227 | | |----(D)--- Redirection URI ---->| Web-Hosted | 228 | | | without Fragment | Client | 229 | | | | Resource | 230 | | (F) |<---(E)------- Script ---------<| | 231 | | | +---------------+ 232 | +-|--------+ 233 | | | 234 | (A) (G) Access Token 235 | | | 236 | ^ v 237 | +---------+ 238 | | | 239 | | Client | 240 | | | 241 | +---------+ 242 | ``` 243 | 244 | ``` 245 | open http://localhost:8080/authorize?client_id=client_id_hash&redirect_uri=https%3A%2F%2Fexample.com%2Foauth2%2Fcallback&response_type=token&state=somestate&scope=read_write 246 | ``` 247 | 248 | 1. Login via the web form (john@example.com | Pass123!) 249 | 1. Click button to grant access 250 | 1. The authorization server redirects back to the redirection URI including an access token and any 251 | state provided by the client in the URI fragment 252 | 253 | ``` 254 | https://example.com/oauth2/callback#access_token=1e21103279e549779a9b5c07d50e641d&expires_at=1574371565&scope=read_write&state=somestate&token_type=Bearer 255 | ``` 256 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - add support for PASETO or JWT bearer tokens 4 | - Add admin UI to manage users and client applications 5 | - use `rangedb.cqrs` package 6 | - add examples for each grant flow 7 | - wiki documentation 8 | - verify there are api-level tests for each situation in https://tools.ietf.org/html/rfc6749 9 | - Add support for PKCE-enhanced Authorization Code Flow https://datatracker.ietf.org/doc/html/rfc7636 10 | -------------------------------------------------------------------------------- /authorization_code.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "github.com/inklabs/rangedb" 5 | "github.com/inklabs/rangedb/pkg/clock" 6 | ) 7 | 8 | // AuthorizationCodeCommandTypes returns all command types goauth2.authorizationCode supports. 9 | func AuthorizationCodeCommandTypes() []string { 10 | return []string{ 11 | RequestAccessTokenViaAuthorizationCodeGrant{}.CommandType(), 12 | IssueAuthorizationCodeToUser{}.CommandType(), 13 | } 14 | } 15 | 16 | type authorizationCode struct { 17 | tokenGenerator TokenGenerator 18 | clock clock.Clock 19 | IsLoaded bool 20 | ExpiresAt int64 21 | UserID string 22 | ClientID string 23 | Scope string 24 | HasBeenPreviouslyUsed bool 25 | PendingEvents []rangedb.Event 26 | } 27 | 28 | func newAuthorizationCode(iter rangedb.RecordIterator, generator TokenGenerator, clock clock.Clock) *authorizationCode { 29 | aggregate := &authorizationCode{ 30 | tokenGenerator: generator, 31 | clock: clock, 32 | } 33 | 34 | for iter.Next() { 35 | if event, ok := iter.Record().Data.(rangedb.Event); ok { 36 | aggregate.apply(event) 37 | } 38 | } 39 | 40 | return aggregate 41 | } 42 | 43 | func (a *authorizationCode) apply(event rangedb.Event) { 44 | switch e := event.(type) { 45 | 46 | case *AuthorizationCodeWasIssuedToUser: 47 | a.IsLoaded = true 48 | a.ExpiresAt = e.ExpiresAt 49 | a.UserID = e.UserID 50 | a.ClientID = e.ClientID 51 | a.Scope = e.Scope 52 | 53 | case *AccessTokenWasIssuedToUserViaAuthorizationCodeGrant: 54 | a.HasBeenPreviouslyUsed = true 55 | 56 | case *RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant: 57 | a.HasBeenPreviouslyUsed = true 58 | 59 | } 60 | } 61 | 62 | func (a *authorizationCode) Handle(command Command) { 63 | switch c := command.(type) { 64 | 65 | case RequestAccessTokenViaAuthorizationCodeGrant: 66 | a.RequestAccessTokenViaAuthorizationCodeGrant(c) 67 | 68 | case IssueAuthorizationCodeToUser: 69 | a.IssueAuthorizationCodeToUser(c) 70 | 71 | } 72 | } 73 | 74 | func (a *authorizationCode) RequestAccessTokenViaAuthorizationCodeGrant(c RequestAccessTokenViaAuthorizationCodeGrant) { 75 | if !a.IsLoaded { 76 | a.raise(RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidAuthorizationCode{ 77 | AuthorizationCode: c.AuthorizationCode, 78 | ClientID: c.ClientID, 79 | }) 80 | return 81 | } 82 | 83 | if a.ClientID != c.ClientID { 84 | a.raise(RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToUnmatchedClientApplicationID{ 85 | AuthorizationCode: c.AuthorizationCode, 86 | RequestedClientID: c.ClientID, 87 | ActualClientID: a.ClientID, 88 | }) 89 | return 90 | } 91 | 92 | if a.HasBeenPreviouslyUsed { 93 | a.raise(RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToPreviouslyUsedAuthorizationCode{ 94 | AuthorizationCode: c.AuthorizationCode, 95 | ClientID: c.ClientID, 96 | UserID: a.UserID, 97 | }) 98 | return 99 | } 100 | 101 | if a.isExpired() { 102 | a.raise(RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToExpiredAuthorizationCode{ 103 | AuthorizationCode: c.AuthorizationCode, 104 | ClientID: c.ClientID, 105 | }) 106 | return 107 | } 108 | 109 | refreshToken := a.tokenGenerator.New() 110 | expiresAt := a.clock.Now().Add(authorizationCodeGrantLifetime).Unix() 111 | 112 | a.raise( 113 | AccessTokenWasIssuedToUserViaAuthorizationCodeGrant{ 114 | AuthorizationCode: c.AuthorizationCode, 115 | UserID: a.UserID, 116 | ClientID: c.ClientID, 117 | Scope: a.Scope, 118 | ExpiresAt: expiresAt, 119 | }, 120 | RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant{ 121 | AuthorizationCode: c.AuthorizationCode, 122 | UserID: a.UserID, 123 | ClientID: c.ClientID, 124 | RefreshToken: refreshToken, 125 | Scope: a.Scope, 126 | }, 127 | ) 128 | } 129 | 130 | func (a *authorizationCode) IssueAuthorizationCodeToUser(c IssueAuthorizationCodeToUser) { 131 | a.raise(AuthorizationCodeWasIssuedToUser{ 132 | AuthorizationCode: c.AuthorizationCode, 133 | UserID: c.UserID, 134 | ClientID: c.ClientID, 135 | ExpiresAt: c.ExpiresAt, 136 | Scope: c.Scope, 137 | }) 138 | } 139 | 140 | func (a *authorizationCode) GetPendingEvents() []rangedb.Event { 141 | return a.PendingEvents 142 | } 143 | 144 | func (a *authorizationCode) isExpired() bool { 145 | return a.clock.Now().Unix() > a.ExpiresAt 146 | } 147 | 148 | func (a *authorizationCode) raise(events ...rangedb.Event) { 149 | for _, event := range events { 150 | a.apply(event) 151 | } 152 | 153 | a.PendingEvents = append(a.PendingEvents, events...) 154 | } 155 | -------------------------------------------------------------------------------- /authorization_code_commands.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/commandgenerator -id AuthorizationCode -aggregateType authorization-code 4 | 5 | type RequestAccessTokenViaAuthorizationCodeGrant struct { 6 | AuthorizationCode string `json:"authorizationCode"` 7 | ClientID string `json:"clientID"` 8 | ClientSecret string `json:"clientSecret"` 9 | RedirectURI string `json:"redirectURI"` 10 | } 11 | type IssueAuthorizationCodeToUser struct { 12 | AuthorizationCode string `json:"authorizationCode"` 13 | UserID string `json:"userID"` 14 | ClientID string `json:"clientID"` 15 | ExpiresAt int64 `json:"expiresAt"` 16 | Scope string `json:"scope"` 17 | } 18 | -------------------------------------------------------------------------------- /authorization_code_events.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/eventgenerator -id AuthorizationCode -aggregateType authorization-code 4 | 5 | // RequestAccessTokenViaAuthorizationCodeGrant events 6 | 7 | type AuthorizationCodeWasIssuedToUser struct { 8 | AuthorizationCode string `json:"authorizationCode"` 9 | UserID string `json:"userID"` 10 | ClientID string `json:"clientID"` 11 | ExpiresAt int64 `json:"expiresAt"` 12 | Scope string `json:"scope"` 13 | } 14 | type AccessTokenWasIssuedToUserViaAuthorizationCodeGrant struct { 15 | AuthorizationCode string `json:"authorizationCode"` 16 | UserID string `json:"userID"` 17 | ClientID string `json:"clientID"` 18 | Scope string `json:"scope"` 19 | ExpiresAt int64 `json:"expiresAt"` 20 | } 21 | type RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant struct { 22 | AuthorizationCode string `json:"authorizationCode"` 23 | ClientID string `json:"clientID"` 24 | UserID string `json:"userID"` 25 | RefreshToken string `json:"refreshToken"` 26 | Scope string `json:"scope"` 27 | } 28 | type RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationID struct { 29 | AuthorizationCode string `json:"authorizationCode"` 30 | ClientID string `json:"clientID"` 31 | } 32 | type RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationSecret struct { 33 | AuthorizationCode string `json:"authorizationCode"` 34 | ClientID string `json:"clientID"` 35 | } 36 | type RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToUnmatchedClientApplicationID struct { 37 | AuthorizationCode string `json:"authorizationCode"` 38 | RequestedClientID string `json:"requestedClientID"` 39 | ActualClientID string `json:"actualClientID"` 40 | } 41 | type RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationRedirectURI struct { 42 | AuthorizationCode string `json:"authorizationCode"` 43 | ClientID string `json:"clientID"` 44 | RedirectURI string `json:"redirectURI"` 45 | } 46 | type RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidAuthorizationCode struct { 47 | AuthorizationCode string `json:"authorizationCode"` 48 | ClientID string `json:"clientID"` 49 | } 50 | type RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToExpiredAuthorizationCode struct { 51 | AuthorizationCode string `json:"authorizationCode"` 52 | ClientID string `json:"clientID"` 53 | } 54 | type RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToPreviouslyUsedAuthorizationCode struct { 55 | AuthorizationCode string `json:"authorizationCode"` 56 | ClientID string `json:"clientID"` 57 | UserID string `json:"userID"` 58 | } 59 | -------------------------------------------------------------------------------- /authorization_code_process_manager.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "github.com/inklabs/rangedb" 5 | ) 6 | 7 | type authorizationCodeProcessManager struct { 8 | dispatch CommandDispatcher 9 | } 10 | 11 | func newAuthorizationCodeProcessManager(commandDispatcher CommandDispatcher) *authorizationCodeProcessManager { 12 | return &authorizationCodeProcessManager{ 13 | dispatch: commandDispatcher, 14 | } 15 | } 16 | 17 | // Accept receives a rangedb.Record. 18 | func (r *authorizationCodeProcessManager) Accept(record *rangedb.Record) { 19 | switch event := record.Data.(type) { 20 | case *AuthorizationCodeWasIssuedToUserViaAuthorizationCodeGrant: 21 | r.dispatch(IssueAuthorizationCodeToUser{ 22 | AuthorizationCode: event.AuthorizationCode, 23 | UserID: event.UserID, 24 | ClientID: event.ClientID, 25 | ExpiresAt: event.ExpiresAt, 26 | Scope: event.Scope, 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /authorization_code_refresh_tokens_projection.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | // AuthorizationCodeRefreshTokens is a projection mapping authorization codes to refresh tokens. 10 | type AuthorizationCodeRefreshTokens struct { 11 | refreshTokensByAuthorizationCode map[string][]string 12 | authorizationCodeByRefreshToken map[string]string 13 | } 14 | 15 | // NewAuthorizationCodeRefreshTokens constructs an AuthorizationCodeRefreshTokens projection. 16 | func NewAuthorizationCodeRefreshTokens() *AuthorizationCodeRefreshTokens { 17 | return &AuthorizationCodeRefreshTokens{ 18 | refreshTokensByAuthorizationCode: make(map[string][]string), 19 | authorizationCodeByRefreshToken: make(map[string]string), 20 | } 21 | } 22 | 23 | // Accept receives a rangedb.Record. 24 | func (a *AuthorizationCodeRefreshTokens) Accept(record *rangedb.Record) { 25 | switch event := record.Data.(type) { 26 | 27 | case *RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant: 28 | a.addRefreshToken(event.AuthorizationCode, event.RefreshToken) 29 | 30 | case *RefreshTokenWasIssuedToUserViaRefreshTokenGrant: 31 | if authorizationCode, ok := a.authorizationCodeByRefreshToken[event.RefreshToken]; ok { 32 | a.addRefreshToken(authorizationCode, event.NextRefreshToken) 33 | } 34 | 35 | case *RefreshTokenWasRevokedFromUser: 36 | if authorizationCode, ok := a.authorizationCodeByRefreshToken[event.RefreshToken]; ok { 37 | a.removeRefreshTokens(authorizationCode) 38 | } 39 | 40 | } 41 | } 42 | 43 | // GetTokens returns all refresh tokens by authorizationCode. 44 | func (a *AuthorizationCodeRefreshTokens) GetTokens(authorizationCode string) []string { 45 | return a.refreshTokensByAuthorizationCode[authorizationCode] 46 | } 47 | 48 | // GetAuthorizationCode returns a single authorization code from a refresh token. 49 | func (a *AuthorizationCodeRefreshTokens) GetAuthorizationCode(refreshToken string) (string, error) { 50 | if authorizationCode, ok := a.authorizationCodeByRefreshToken[refreshToken]; ok { 51 | return authorizationCode, nil 52 | } 53 | 54 | return "", ErrAuthorizationCodeNotFound 55 | } 56 | 57 | func (a *AuthorizationCodeRefreshTokens) addRefreshToken(authorizationCode, refreshToken string) { 58 | a.authorizationCodeByRefreshToken[refreshToken] = authorizationCode 59 | a.refreshTokensByAuthorizationCode[authorizationCode] = append( 60 | a.refreshTokensByAuthorizationCode[authorizationCode], 61 | refreshToken, 62 | ) 63 | } 64 | 65 | func (a *AuthorizationCodeRefreshTokens) removeRefreshTokens(authorizationCode string) { 66 | for _, refreshToken := range a.refreshTokensByAuthorizationCode[authorizationCode] { 67 | delete(a.authorizationCodeByRefreshToken, refreshToken) 68 | } 69 | 70 | delete(a.refreshTokensByAuthorizationCode, authorizationCode) 71 | } 72 | 73 | // ErrAuthorizationCodeNotFound is a defined error for missing authorization code. 74 | var ErrAuthorizationCodeNotFound = fmt.Errorf("authorization code not found") 75 | -------------------------------------------------------------------------------- /authorization_code_refresh_tokens_projection_test.go: -------------------------------------------------------------------------------- 1 | package goauth2_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/inklabs/rangedb/rangedbtest" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/inklabs/goauth2" 11 | ) 12 | 13 | func TestAuthorizationCodeRefreshTokens(t *testing.T) { 14 | // Given 15 | const ( 16 | authorizationCode = "4da3044aa1ce4ef7a37c6a32375cf39a" 17 | userID = "e2c27a06cd7c4003878979641e9e8288" 18 | clientID = "17608d60710947d9803abc596184484b" 19 | refreshToken1 = "3afb212eef99411d91f888cf826c289f" 20 | refreshToken2 = "3754ba23f805431e83e0d50491311539" 21 | scope = "read_write" 22 | ) 23 | 24 | t.Run("authorization code is associated with one refresh token via authorization code grant", func(t *testing.T) { 25 | // Given 26 | authorizationCodeRefreshTokens := goauth2.NewAuthorizationCodeRefreshTokens() 27 | record := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant{ 28 | AuthorizationCode: authorizationCode, 29 | ClientID: clientID, 30 | UserID: userID, 31 | RefreshToken: refreshToken1, 32 | Scope: scope, 33 | }) 34 | 35 | // When 36 | authorizationCodeRefreshTokens.Accept(record) 37 | 38 | // Then 39 | tokens := authorizationCodeRefreshTokens.GetTokens(authorizationCode) 40 | assert.Equal(t, []string{refreshToken1}, tokens) 41 | actualAuthorizationCode, err := authorizationCodeRefreshTokens.GetAuthorizationCode(refreshToken1) 42 | require.NoError(t, err) 43 | assert.Equal(t, authorizationCode, actualAuthorizationCode) 44 | }) 45 | 46 | t.Run("authorization code is associated with another refresh token via refresh token grant", func(t *testing.T) { 47 | // Given 48 | authorizationCodeRefreshTokens := goauth2.NewAuthorizationCodeRefreshTokens() 49 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant{ 50 | AuthorizationCode: authorizationCode, 51 | ClientID: clientID, 52 | UserID: userID, 53 | RefreshToken: refreshToken1, 54 | Scope: scope, 55 | }) 56 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasIssuedToUserViaRefreshTokenGrant{ 57 | RefreshToken: refreshToken1, 58 | UserID: userID, 59 | ClientID: clientID, 60 | NextRefreshToken: refreshToken2, 61 | Scope: scope, 62 | }) 63 | 64 | // When 65 | authorizationCodeRefreshTokens.Accept(record1) 66 | authorizationCodeRefreshTokens.Accept(record2) 67 | 68 | // Then 69 | assert.Equal(t, []string{refreshToken1, refreshToken2}, authorizationCodeRefreshTokens.GetTokens(authorizationCode)) 70 | actualAuthorizationCode1, err := authorizationCodeRefreshTokens.GetAuthorizationCode(refreshToken1) 71 | require.NoError(t, err) 72 | actualAuthorizationCode2, err := authorizationCodeRefreshTokens.GetAuthorizationCode(refreshToken2) 73 | require.NoError(t, err) 74 | assert.Equal(t, authorizationCode, actualAuthorizationCode1) 75 | assert.Equal(t, authorizationCode, actualAuthorizationCode2) 76 | }) 77 | 78 | t.Run("remove one revoked token to avoid memory leak", func(t *testing.T) { 79 | // Given 80 | authorizationCodeRefreshTokens := goauth2.NewAuthorizationCodeRefreshTokens() 81 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant{ 82 | AuthorizationCode: authorizationCode, 83 | ClientID: clientID, 84 | UserID: userID, 85 | RefreshToken: refreshToken1, 86 | Scope: scope, 87 | }) 88 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasRevokedFromUser{ 89 | RefreshToken: refreshToken1, 90 | UserID: userID, 91 | ClientID: clientID, 92 | }) 93 | 94 | // When 95 | authorizationCodeRefreshTokens.Accept(record1) 96 | authorizationCodeRefreshTokens.Accept(record2) 97 | 98 | // Then 99 | assert.Equal(t, []string(nil), authorizationCodeRefreshTokens.GetTokens(authorizationCode)) 100 | actualAuthorizationCode, err := authorizationCodeRefreshTokens.GetAuthorizationCode(refreshToken1) 101 | assert.Equal(t, "", actualAuthorizationCode) 102 | assert.Equal(t, goauth2.ErrAuthorizationCodeNotFound, err) 103 | }) 104 | 105 | t.Run("remove two revoked tokens to avoid memory leak", func(t *testing.T) { 106 | // Given 107 | authorizationCodeRefreshTokens := goauth2.NewAuthorizationCodeRefreshTokens() 108 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant{ 109 | AuthorizationCode: authorizationCode, 110 | ClientID: clientID, 111 | UserID: userID, 112 | RefreshToken: refreshToken1, 113 | Scope: scope, 114 | }) 115 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasIssuedToUserViaRefreshTokenGrant{ 116 | RefreshToken: refreshToken1, 117 | UserID: userID, 118 | ClientID: clientID, 119 | NextRefreshToken: refreshToken2, 120 | Scope: scope, 121 | }) 122 | record3 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasRevokedFromUser{ 123 | RefreshToken: refreshToken1, 124 | UserID: userID, 125 | ClientID: clientID, 126 | }) 127 | record4 := rangedbtest.DummyRecordFromEvent(&goauth2.RefreshTokenWasRevokedFromUser{ 128 | RefreshToken: refreshToken2, 129 | UserID: userID, 130 | ClientID: clientID, 131 | }) 132 | 133 | // When 134 | authorizationCodeRefreshTokens.Accept(record1) 135 | authorizationCodeRefreshTokens.Accept(record2) 136 | authorizationCodeRefreshTokens.Accept(record3) 137 | authorizationCodeRefreshTokens.Accept(record4) 138 | 139 | // Then 140 | assert.Equal(t, []string(nil), authorizationCodeRefreshTokens.GetTokens(authorizationCode)) 141 | actualAuthorizationCode1, err := authorizationCodeRefreshTokens.GetAuthorizationCode(refreshToken1) 142 | assert.Equal(t, goauth2.ErrAuthorizationCodeNotFound, err) 143 | actualAuthorizationCode2, err := authorizationCodeRefreshTokens.GetAuthorizationCode(refreshToken2) 144 | assert.Equal(t, goauth2.ErrAuthorizationCodeNotFound, err) 145 | assert.Equal(t, "", actualAuthorizationCode1) 146 | assert.Equal(t, "", actualAuthorizationCode2) 147 | }) 148 | 149 | t.Run("no events", func(t *testing.T) { 150 | // Given 151 | authorizationCodeRefreshTokens := goauth2.NewAuthorizationCodeRefreshTokens() 152 | 153 | // When 154 | tokens := authorizationCodeRefreshTokens.GetTokens(authorizationCode) 155 | 156 | // Then 157 | assert.Equal(t, []string(nil), tokens) 158 | actualAuthorizationCode, err := authorizationCodeRefreshTokens.GetAuthorizationCode(refreshToken1) 159 | assert.Equal(t, goauth2.ErrAuthorizationCodeNotFound, err) 160 | assert.Equal(t, "", actualAuthorizationCode) 161 | }) 162 | } 163 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14.3-alpine AS build 2 | 3 | RUN apk add --update --no-cache ca-certificates gcc g++ libc-dev 4 | 5 | WORKDIR /code 6 | 7 | # Download Dependencies 8 | COPY go.mod . 9 | COPY go.sum . 10 | RUN go mod download 11 | 12 | # Test 13 | COPY . . 14 | RUN go generate ./... && go vet ./... && go test ./... 15 | 16 | # Build 17 | RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags -static" -o /go/bin/goauth2 ./cmd/goauth2 18 | 19 | # Prepare final image 20 | FROM scratch AS release 21 | COPY --from=build /go/bin/goauth2 /bin/goauth2 22 | ENTRYPOINT ["/bin/goauth2"] 23 | EXPOSE 8080 24 | -------------------------------------------------------------------------------- /build/README.md: -------------------------------------------------------------------------------- 1 | # GOAuth2 Docker 2 | 3 | Docker container automated builds: https://hub.docker.com/r/inklabs/goauth2 4 | 5 | ## Building Locally 6 | 7 | ### Build Image 8 | 9 | ``` 10 | docker build -f build/Dockerfile -t inklabs/goauth2:local . 11 | ``` 12 | 13 | ### Run Container 14 | 15 | ``` 16 | docker run -p 8080:8080 inklabs/goauth2:local 17 | ``` 18 | -------------------------------------------------------------------------------- /client_application.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "net/url" 5 | "time" 6 | 7 | "github.com/inklabs/rangedb" 8 | "github.com/inklabs/rangedb/pkg/clock" 9 | ) 10 | 11 | const clientApplicationGrantLifetime = 1 * time.Hour 12 | 13 | // ClientApplicationCommandTypes returns all command types goauth2.clientApplication supports. 14 | func ClientApplicationCommandTypes() []string { 15 | return []string{ 16 | OnBoardClientApplication{}.CommandType(), 17 | RequestAccessTokenViaClientCredentialsGrant{}.CommandType(), 18 | } 19 | } 20 | 21 | type clientApplication struct { 22 | IsOnBoarded bool 23 | ClientID string 24 | ClientSecret string 25 | RedirectURI string 26 | pendingEvents []rangedb.Event 27 | clock clock.Clock 28 | } 29 | 30 | func newClientApplication(iter rangedb.RecordIterator, clock clock.Clock) *clientApplication { 31 | aggregate := &clientApplication{ 32 | clock: clock, 33 | } 34 | 35 | for iter.Next() { 36 | if event, ok := iter.Record().Data.(rangedb.Event); ok { 37 | aggregate.apply(event) 38 | } 39 | } 40 | 41 | return aggregate 42 | } 43 | 44 | func (a *clientApplication) apply(event rangedb.Event) { 45 | switch e := event.(type) { 46 | case *ClientApplicationWasOnBoarded: 47 | a.IsOnBoarded = true 48 | a.ClientID = e.ClientID 49 | a.ClientSecret = e.ClientSecret 50 | a.RedirectURI = e.RedirectURI 51 | 52 | } 53 | } 54 | 55 | func (a *clientApplication) GetPendingEvents() []rangedb.Event { 56 | return a.pendingEvents 57 | } 58 | 59 | func (a *clientApplication) Handle(command Command) { 60 | switch c := command.(type) { 61 | 62 | case OnBoardClientApplication: 63 | a.OnBoardClientApplication(c) 64 | 65 | case RequestAccessTokenViaClientCredentialsGrant: 66 | a.RequestAccessTokenViaClientCredentialsGrant(c) 67 | 68 | } 69 | } 70 | 71 | func (a *clientApplication) OnBoardClientApplication(c OnBoardClientApplication) { 72 | uri, err := url.Parse(c.RedirectURI) 73 | if err != nil { 74 | a.raise(OnBoardClientApplicationWasRejectedDueToInvalidRedirectURI{ 75 | ClientID: c.ClientID, 76 | RedirectURI: c.RedirectURI, 77 | }) 78 | return 79 | } 80 | 81 | if uri.Scheme != "https" { 82 | a.raise(OnBoardClientApplicationWasRejectedDueToInsecureRedirectURI{ 83 | ClientID: c.ClientID, 84 | RedirectURI: c.RedirectURI, 85 | }) 86 | return 87 | } 88 | 89 | a.raise(ClientApplicationWasOnBoarded{ 90 | ClientID: c.ClientID, 91 | ClientSecret: c.ClientSecret, 92 | RedirectURI: c.RedirectURI, 93 | UserID: c.UserID, 94 | }) 95 | } 96 | 97 | func (a *clientApplication) RequestAccessTokenViaClientCredentialsGrant(c RequestAccessTokenViaClientCredentialsGrant) { 98 | if !a.IsOnBoarded { 99 | a.raise(RequestAccessTokenViaClientCredentialsGrantWasRejectedDueToInvalidClientApplicationID{ 100 | ClientID: c.ClientID, 101 | }) 102 | return 103 | } 104 | 105 | if a.ClientSecret != c.ClientSecret { 106 | a.raise(RequestAccessTokenViaClientCredentialsGrantWasRejectedDueToInvalidClientApplicationSecret{ 107 | ClientID: c.ClientID, 108 | }) 109 | return 110 | } 111 | 112 | expiresAt := a.clock.Now().Add(clientApplicationGrantLifetime).Unix() 113 | 114 | a.raise(AccessTokenWasIssuedToClientApplicationViaClientCredentialsGrant{ 115 | ClientID: c.ClientID, 116 | ExpiresAt: expiresAt, 117 | Scope: c.Scope, 118 | }) 119 | } 120 | 121 | func (a *clientApplication) raise(events ...rangedb.Event) { 122 | for _, event := range events { 123 | a.apply(event) 124 | } 125 | 126 | a.pendingEvents = append(a.pendingEvents, events...) 127 | } 128 | -------------------------------------------------------------------------------- /client_application_command_authorization.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/clock" 8 | ) 9 | 10 | type clientApplicationCommandAuthorization struct { 11 | store rangedb.Store 12 | clock clock.Clock 13 | pendingEvents []rangedb.Event 14 | } 15 | 16 | func newClientApplicationCommandAuthorization( 17 | store rangedb.Store, 18 | clock clock.Clock, 19 | ) *clientApplicationCommandAuthorization { 20 | return &clientApplicationCommandAuthorization{ 21 | store: store, 22 | clock: clock, 23 | } 24 | } 25 | 26 | func (a *clientApplicationCommandAuthorization) GetPendingEvents() []rangedb.Event { 27 | return a.pendingEvents 28 | } 29 | 30 | func (a *clientApplicationCommandAuthorization) CommandTypes() []string { 31 | return []string{ 32 | RequestAccessTokenViaImplicitGrant{}.CommandType(), 33 | RequestAccessTokenViaROPCGrant{}.CommandType(), 34 | RequestAccessTokenViaRefreshTokenGrant{}.CommandType(), 35 | RequestAuthorizationCodeViaAuthorizationCodeGrant{}.CommandType(), 36 | RequestAccessTokenViaAuthorizationCodeGrant{}.CommandType(), 37 | } 38 | } 39 | 40 | func (a *clientApplicationCommandAuthorization) Handle(command Command) bool { 41 | switch c := command.(type) { 42 | 43 | case RequestAccessTokenViaImplicitGrant: 44 | return a.RequestAccessTokenViaImplicitGrant(c) 45 | 46 | case RequestAccessTokenViaROPCGrant: 47 | return a.RequestAccessTokenViaROPCGrant(c) 48 | 49 | case RequestAccessTokenViaRefreshTokenGrant: 50 | return a.RequestAccessTokenViaRefreshTokenGrant(c) 51 | 52 | case RequestAuthorizationCodeViaAuthorizationCodeGrant: 53 | return a.RequestAuthorizationCodeViaAuthorizationCodeGrant(c) 54 | 55 | case RequestAccessTokenViaAuthorizationCodeGrant: 56 | return a.RequestAccessTokenViaAuthorizationCodeGrant(c) 57 | 58 | } 59 | 60 | return true 61 | } 62 | 63 | func (a *clientApplicationCommandAuthorization) RequestAccessTokenViaImplicitGrant(c RequestAccessTokenViaImplicitGrant) bool { 64 | clientApplication := a.loadClientApplicationAggregate(c.ClientID) 65 | 66 | if !clientApplication.IsOnBoarded { 67 | a.raise(RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidClientApplicationID{ 68 | UserID: c.UserID, 69 | ClientID: c.ClientID, 70 | }) 71 | return false 72 | } 73 | 74 | if clientApplication.RedirectURI != c.RedirectURI { 75 | a.raise(RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidClientApplicationRedirectURI{ 76 | UserID: c.UserID, 77 | ClientID: c.ClientID, 78 | RedirectURI: c.RedirectURI, 79 | }) 80 | return false 81 | } 82 | 83 | return true 84 | } 85 | 86 | func (a *clientApplicationCommandAuthorization) RequestAccessTokenViaROPCGrant(c RequestAccessTokenViaROPCGrant) bool { 87 | clientApplication := a.loadClientApplicationAggregate(c.ClientID) 88 | 89 | if !clientApplication.IsOnBoarded { 90 | a.raise(RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidClientApplicationCredentials{ 91 | UserID: c.UserID, 92 | ClientID: c.ClientID, 93 | }) 94 | return false 95 | } 96 | 97 | if clientApplication.ClientSecret != c.ClientSecret { 98 | a.raise(RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidClientApplicationCredentials{ 99 | UserID: c.UserID, 100 | ClientID: c.ClientID, 101 | }) 102 | return false 103 | } 104 | 105 | return true 106 | } 107 | 108 | func (a *clientApplicationCommandAuthorization) RequestAccessTokenViaRefreshTokenGrant(c RequestAccessTokenViaRefreshTokenGrant) bool { 109 | clientApplication := a.loadClientApplicationAggregate(c.ClientID) 110 | 111 | if !clientApplication.IsOnBoarded { 112 | a.raise(RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToInvalidClientApplicationCredentials{ 113 | RefreshToken: c.RefreshToken, 114 | ClientID: c.ClientID, 115 | }) 116 | return false 117 | } 118 | 119 | if clientApplication.ClientSecret != c.ClientSecret { 120 | a.raise(RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToInvalidClientApplicationCredentials{ 121 | RefreshToken: c.RefreshToken, 122 | ClientID: c.ClientID, 123 | }) 124 | return false 125 | } 126 | 127 | return true 128 | } 129 | 130 | func (a *clientApplicationCommandAuthorization) RequestAuthorizationCodeViaAuthorizationCodeGrant(c RequestAuthorizationCodeViaAuthorizationCodeGrant) bool { 131 | clientApplication := a.loadClientApplicationAggregate(c.ClientID) 132 | 133 | if !clientApplication.IsOnBoarded { 134 | a.raise(RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationID{ 135 | UserID: c.UserID, 136 | ClientID: c.ClientID, 137 | }) 138 | return false 139 | } 140 | 141 | if clientApplication.RedirectURI != c.RedirectURI { 142 | a.raise(RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationRedirectURI{ 143 | UserID: c.UserID, 144 | ClientID: c.ClientID, 145 | RedirectURI: c.RedirectURI, 146 | }) 147 | return false 148 | } 149 | 150 | return true 151 | } 152 | 153 | func (a *clientApplicationCommandAuthorization) RequestAccessTokenViaAuthorizationCodeGrant(c RequestAccessTokenViaAuthorizationCodeGrant) bool { 154 | clientApplication := a.loadClientApplicationAggregate(c.ClientID) 155 | 156 | if !clientApplication.IsOnBoarded { 157 | a.raise(RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationID{ 158 | AuthorizationCode: c.AuthorizationCode, 159 | ClientID: c.ClientID, 160 | }) 161 | return false 162 | } 163 | 164 | if clientApplication.ClientSecret != c.ClientSecret { 165 | a.raise(RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationSecret{ 166 | AuthorizationCode: c.AuthorizationCode, 167 | ClientID: c.ClientID, 168 | }) 169 | return false 170 | } 171 | 172 | if clientApplication.RedirectURI != c.RedirectURI { 173 | a.raise(RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationRedirectURI{ 174 | AuthorizationCode: c.AuthorizationCode, 175 | ClientID: c.ClientID, 176 | RedirectURI: c.RedirectURI, 177 | }) 178 | return false 179 | } 180 | 181 | return true 182 | } 183 | 184 | func (a *clientApplicationCommandAuthorization) loadClientApplicationAggregate(clientID string) *clientApplication { 185 | ctx := context.Background() 186 | return newClientApplication( 187 | a.store.EventsByStream(ctx, 0, clientApplicationStream(clientID)), 188 | a.clock, 189 | ) 190 | } 191 | 192 | func (a *clientApplicationCommandAuthorization) raise(events ...rangedb.Event) { 193 | a.pendingEvents = append(a.pendingEvents, events...) 194 | } 195 | -------------------------------------------------------------------------------- /client_application_commands.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/commandgenerator -id ClientID -aggregateType client-application 4 | 5 | type OnBoardClientApplication struct { 6 | ClientID string `json:"clientID"` 7 | ClientSecret string `json:"clientSecret"` 8 | RedirectURI string `json:"redirectURI"` 9 | UserID string `json:"userID"` 10 | } 11 | type RequestAccessTokenViaClientCredentialsGrant struct { 12 | ClientID string `json:"clientID"` 13 | ClientSecret string `json:"clientSecret"` 14 | Scope string `json:"scope"` 15 | } 16 | -------------------------------------------------------------------------------- /client_application_events.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/eventgenerator -id ClientID -aggregateType client-application 4 | 5 | // OnBoardClientApplication Events 6 | 7 | type ClientApplicationWasOnBoarded struct { 8 | ClientID string `json:"clientID"` 9 | ClientSecret string `json:"clientSecret"` 10 | RedirectURI string `json:"redirectURI"` 11 | UserID string `json:"userID"` 12 | } 13 | type OnBoardClientApplicationWasRejectedDueToUnAuthorizeUser struct { 14 | ClientID string `json:"clientID"` 15 | UserID string `json:"userID"` 16 | } 17 | type OnBoardClientApplicationWasRejectedDueToInsecureRedirectURI struct { 18 | ClientID string `json:"clientID"` 19 | RedirectURI string `json:"redirectURI"` 20 | } 21 | type OnBoardClientApplicationWasRejectedDueToInvalidRedirectURI struct { 22 | ClientID string `json:"clientID"` 23 | RedirectURI string `json:"redirectURI"` 24 | } 25 | 26 | // RequestAccessTokenViaClientCredentialsGrant Events 27 | 28 | type AccessTokenWasIssuedToClientApplicationViaClientCredentialsGrant struct { 29 | ClientID string `json:"clientID"` 30 | ExpiresAt int64 `json:"expiresAt"` 31 | Scope string `json:"scope"` 32 | } 33 | type RequestAccessTokenViaClientCredentialsGrantWasRejectedDueToInvalidClientApplicationID struct { 34 | ClientID string `json:"clientID"` 35 | } 36 | type RequestAccessTokenViaClientCredentialsGrantWasRejectedDueToInvalidClientApplicationSecret struct { 37 | ClientID string `json:"clientID"` 38 | } 39 | -------------------------------------------------------------------------------- /cmd/goauth2/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "text/template" 13 | 14 | "github.com/gorilla/securecookie" 15 | "github.com/inklabs/rangedb" 16 | "github.com/inklabs/rangedb/pkg/rangedbapi" 17 | "github.com/inklabs/rangedb/pkg/rangedbui" 18 | "github.com/inklabs/rangedb/provider/inmemorystore" 19 | 20 | "github.com/inklabs/goauth2" 21 | "github.com/inklabs/goauth2/web" 22 | ) 23 | 24 | func main() { 25 | fmt.Println("OAuth2 Server") 26 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 27 | 28 | requestedOauth2Port := flag.Uint("port", 0, "port") 29 | templatesPath := flag.String("templates", "", "optional templates path") 30 | csrfAuthKey := flag.String("csrfAuthKey", string(securecookie.GenerateRandomKey(32)), "csrf authentication key") 31 | sessionAuthKey := flag.String("sessionAuthKey", string(securecookie.GenerateRandomKey(64)), "cookie session auth key (64 bytes)") 32 | sessionEncryptionKey := flag.String("sessionEncryptionKey", string(securecookie.GenerateRandomKey(32)), "cookie session encryption key (32 bytes)") 33 | previousSessionAuthKey := flag.String("previousSessionAuthKey", "", "previous 64 byte cookie session auth key (used for key rotation)") 34 | previousSessionEncryptionKey := flag.String("previousSessionEncryptionKey", "", "previous 32 byte cookie session encryption key (used for key rotation)") 35 | flag.Parse() 36 | 37 | oAuth2Listener, err := getListener(*requestedOauth2Port) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | store := inmemorystore.New( 43 | inmemorystore.WithLogger(log.New(os.Stderr, "", 0)), 44 | ) 45 | goAuth2App, err := goauth2.New(goauth2.WithStore(store)) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | sessionKeyPairs := []web.SessionKeyPair{ 51 | { 52 | AuthenticationKey: []byte(*sessionAuthKey), 53 | EncryptionKey: []byte(*sessionEncryptionKey), 54 | }, 55 | } 56 | 57 | if *previousSessionAuthKey != "" && *previousSessionEncryptionKey != "" { 58 | sessionKeyPairs = append(sessionKeyPairs, web.SessionKeyPair{ 59 | AuthenticationKey: []byte(*previousSessionAuthKey), 60 | EncryptionKey: []byte(*previousSessionEncryptionKey), 61 | }) 62 | } 63 | 64 | goAuth2WebAppOptions := []web.Option{ 65 | web.WithGoAuth2App(goAuth2App), 66 | web.WithHost(oAuth2Listener.Addr().String()), 67 | web.WithCSRFAuthKey([]byte(*csrfAuthKey)), 68 | web.WithSessionKeyPair(sessionKeyPairs...), 69 | } 70 | 71 | if *templatesPath != "" { 72 | if _, err := os.Stat(*templatesPath); os.IsNotExist(err) { 73 | log.Fatalf("templates path does not exist: %v", err) 74 | } 75 | 76 | templatesFS := os.DirFS(*templatesPath + "/..") 77 | 78 | goAuth2WebAppOptions = append(goAuth2WebAppOptions, web.WithTemplateFS(templatesFS)) 79 | } 80 | 81 | goAuth2webApp, err := web.New(goAuth2WebAppOptions...) 82 | if err != nil { 83 | log.Fatal(err) 84 | } 85 | 86 | err = initDB(goAuth2App, store, oAuth2Listener.Addr().String()) 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | 91 | go func() { 92 | rangeDBListener, err := getListener(0) 93 | rangeDBPort := rangeDBListener.Addr().(*net.TCPAddr).Port 94 | 95 | rangeDBAPIUri := url.URL{ 96 | Scheme: "http", 97 | Host: rangeDBListener.Addr().String(), 98 | Path: "/api", 99 | } 100 | 101 | api, err := rangedbapi.New(rangedbapi.WithStore(store), rangedbapi.WithBaseUri(rangeDBAPIUri.String())) 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | 106 | ui := rangedbui.New( 107 | api.AggregateTypeStatsProjection(), 108 | store, 109 | rangedbui.WithHost(rangeDBListener.Addr().String())) 110 | 111 | server := http.NewServeMux() 112 | server.Handle("/", ui) 113 | server.Handle("/api/", http.StripPrefix("/api", api)) 114 | 115 | fmt.Printf("RangeDB UI: http://0.0.0.0:%d/\n", rangeDBPort) 116 | log.Fatal(http.Serve(rangeDBListener, server)) 117 | }() 118 | 119 | goAuth2Port := oAuth2Listener.Addr().(*net.TCPAddr).Port 120 | fmt.Printf("Go OAuth2 Server:\n") 121 | fmt.Printf(" http://0.0.0.0:%d/login\n", goAuth2Port) 122 | fmt.Printf(" http://0.0.0.0:%d/list-client-applications\n", goAuth2Port) 123 | fmt.Printf(" http://0.0.0.0:%d/list-users\n", goAuth2Port) 124 | log.Fatal(http.Serve(oAuth2Listener, goAuth2webApp)) 125 | } 126 | 127 | func getListener(port uint) (net.Listener, error) { 128 | listener, err := net.Listen("tcp4", fmt.Sprintf(":%d", port)) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | return listener, nil 134 | } 135 | 136 | func initDB(goauth2App *goauth2.App, store rangedb.Store, goAuth2Host string) error { 137 | const ( 138 | userID = "589a2ce8a34442c9a36f8b0659832165" 139 | email = "john@example.com" 140 | password = "Pass123" 141 | userID2 = "03e16d6469bc4d07b4d0c832380e20ce" 142 | email2 = "jane@example.com" 143 | password2 = "Pass123" 144 | clientID = web.ClientIDTODO 145 | clientSecret = web.ClientSecretTODO 146 | ) 147 | 148 | ctx := context.Background() 149 | _, err := store.Save(ctx, 150 | &rangedb.EventRecord{ 151 | Event: goauth2.UserWasOnBoarded{ 152 | UserID: userID, 153 | Username: email, 154 | PasswordHash: goauth2.GeneratePasswordHash(password), 155 | GrantingUserID: userID, 156 | }, 157 | Metadata: map[string]string{ 158 | "message": "epoch event", 159 | }, 160 | }, 161 | &rangedb.EventRecord{ 162 | Event: goauth2.UserWasGrantedAdministratorRole{ 163 | UserID: userID, 164 | GrantingUserID: userID, 165 | }, 166 | Metadata: map[string]string{ 167 | "message": "epoch event", 168 | }, 169 | }, 170 | ) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | goauth2App.Dispatch(goauth2.OnBoardUser{ 176 | UserID: userID2, 177 | Username: email2, 178 | Password: password2, 179 | GrantingUserID: userID, 180 | }) 181 | 182 | goauth2App.Dispatch(goauth2.AuthorizeUserToOnBoardClientApplications{ 183 | UserID: userID, 184 | AuthorizingUserID: userID, 185 | }) 186 | 187 | goauth2App.Dispatch(goauth2.OnBoardClientApplication{ 188 | ClientID: clientID, 189 | ClientSecret: clientSecret, 190 | RedirectURI: "https://example.com/oauth2/callback", 191 | UserID: userID, 192 | }) 193 | 194 | tmpl, err := template.New("docTemplate").Parse(` 195 | Example commands to test grant flows: 196 | 197 | # Client Credentials 198 | curl {{.Host}}/token \ 199 | -u {{.ClientID}}:{{.ClientSecret}} \ 200 | -d "grant_type=client_credentials" \ 201 | -d "scope=read_write" -s | jq 202 | 203 | # Resource Owner Password Credentials 204 | curl {{.Host}}/token \ 205 | -u {{.ClientID}}:{{.ClientSecret}} \ 206 | -d "grant_type=password" \ 207 | -d "username=john@example.com" \ 208 | -d "password=Pass123" \ 209 | -d "scope=read_write" -s | jq 210 | 211 | # Refresh Token 212 | curl {{.Host}}/token \ 213 | -u {{.ClientID}}:{{.ClientSecret}} \ 214 | -d "grant_type=refresh_token" \ 215 | -d "refresh_token=3cc6fa5b470642b081e3ebd29aa9b43c" \ 216 | -d "scope=read_write" -s | jq 217 | 218 | # Refresh Token x2 219 | curl {{.Host}}/token \ 220 | -u {{.ClientID}}:{{.ClientSecret}} \ 221 | -d "grant_type=refresh_token" \ 222 | -d "refresh_token=93b5e8869a954faaa6c6ba73dfea1a09" \ 223 | -d "scope=read_write" -s | jq 224 | 225 | # Authorization Code 226 | http://{{.Host}}/login?client_id=8895e1e5f06644ebb41c26ea5740b246&redirect_uri=https://example.com/oauth2/callback&response_type=code&state=somestate&scope=read_write 227 | user: john@example.com 228 | pass: Pass123 229 | 230 | # Authorization Code Token 231 | curl {{.Host}}/token \ 232 | -u {{.ClientID}}:{{.ClientSecret}} \ 233 | -d "grant_type=authorization_code" \ 234 | -d "code=3cc6fa5b470642b081e3ebd29aa9b43c" \ 235 | -d "redirect_uri=https://example.com/oauth2/callback" -s | jq 236 | 237 | # Implicit 238 | http://{{.Host}}/login?client_id=8895e1e5f06644ebb41c26ea5740b246&redirect_uri=https://example.com/oauth2/callback&response_type=token&state=somestate&scope=read_write 239 | user: john@example.com 240 | pass: Pass123 241 | 242 | `) 243 | if err != nil { 244 | return err 245 | } 246 | 247 | return tmpl.Execute(os.Stdout, struct { 248 | Host string 249 | ClientSecret string 250 | ClientID string 251 | }{ 252 | Host: goAuth2Host, 253 | ClientID: clientID, 254 | ClientSecret: clientSecret, 255 | }) 256 | } 257 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "github.com/inklabs/rangedb" 5 | ) 6 | 7 | // Command is the interface for CQRS commands. 8 | type Command interface { 9 | rangedb.AggregateMessage 10 | CommandType() string 11 | } 12 | 13 | type CommandHandler interface { 14 | PendingEvents 15 | Handle(command Command) 16 | } 17 | 18 | type PreCommandHandler interface { 19 | PendingEvents 20 | CommandTypes() []string 21 | Handle(command Command) (shouldContinue bool) 22 | } 23 | 24 | type CommandDispatcher func(command Command) []rangedb.Event 25 | type CommandHandlerFactory func(command Command) CommandHandler 26 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Go OAuth2 Docs 2 | 3 | ## Event Model 4 | 5 | ![OAuth2 Event Model](https://github.com/inklabs/goauth2/raw/master/docs/images/oauth2-event-model.jpg) 6 | -------------------------------------------------------------------------------- /docs/images/oauth2-event-model.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/goauth2/4806e31e4f6b513ce4f95de27b2c8cd007b68eb7/docs/images/oauth2-event-model.jpg -------------------------------------------------------------------------------- /events.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "github.com/inklabs/rangedb" 5 | ) 6 | 7 | //go:generate go run github.com/inklabs/rangedb/gen/eventbinder -package goauth2 -files client_application_events.go,resource_owner_events.go,refresh_token_events.go,authorization_code_events.go 8 | 9 | // PendingEvents is the interface for retrieving CQRS events that will be saved to the event store. 10 | type PendingEvents interface { 11 | GetPendingEvents() []rangedb.Event 12 | } 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/inklabs/goauth2 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/golang/protobuf v1.4.2 // indirect 8 | github.com/google/uuid v1.3.0 9 | github.com/gorilla/csrf v1.7.1 10 | github.com/gorilla/handlers v1.4.2 // indirect 11 | github.com/gorilla/mux v1.8.0 12 | github.com/gorilla/securecookie v1.1.1 13 | github.com/gorilla/sessions v1.2.1 14 | github.com/inklabs/rangedb v0.12.1-0.20211102191110-c880f5f0baa1 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | github.com/stretchr/testify v1.7.0 17 | github.com/vmihailenco/msgpack/v4 v4.3.11 // indirect 18 | github.com/vmihailenco/tagparser v0.1.1 // indirect 19 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 20 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b 21 | google.golang.org/appengine v1.6.6 // indirect 22 | google.golang.org/protobuf v1.25.0 // indirect 23 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 24 | ) 25 | 26 | require ( 27 | github.com/dustin/go-humanize v1.0.0 // indirect 28 | github.com/gorilla/websocket v1.4.2 // indirect 29 | github.com/pkg/errors v0.9.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/aws/aws-sdk-go v1.38.22/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= 4 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 5 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 6 | github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 11 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 12 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 13 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 14 | github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= 15 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 16 | github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= 17 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 18 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 19 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 20 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 21 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 22 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 23 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 24 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 25 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 26 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 27 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 28 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 29 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 30 | github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= 31 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 32 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 33 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 34 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 35 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 36 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 37 | github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= 38 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 39 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 40 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 41 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 42 | github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE= 43 | github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA= 44 | github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= 45 | github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= 46 | github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 47 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 48 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 49 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 50 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 51 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 52 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 53 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 54 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 55 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 56 | github.com/inklabs/rangedb v0.12.1-0.20211102191110-c880f5f0baa1 h1:OxoacHBTXmzIW8UGKk7owxANzp8plt2rY0F8eaWvOPw= 57 | github.com/inklabs/rangedb v0.12.1-0.20211102191110-c880f5f0baa1/go.mod h1:kyx3GJKnnU0c0Tc6Ubnr+FZ76xWceYjXHr3+VC8Bmn0= 58 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 59 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 60 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 61 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 62 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 63 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 64 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 65 | github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 66 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 67 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 68 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 69 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 70 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 71 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 72 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 74 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 75 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 76 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 77 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 78 | github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= 79 | github.com/vmihailenco/msgpack/v4 v4.3.11 h1:Q47CePddpNGNhk4GCnAx9DDtASi2rasatE0cd26cZoE= 80 | github.com/vmihailenco/msgpack/v4 v4.3.11/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= 81 | github.com/vmihailenco/tagparser v0.1.1 h1:quXMXlA39OCbd2wAdTsGDlK9RkOk6Wuw+x37wVyIuWY= 82 | github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= 83 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 84 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= 85 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 86 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 87 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 88 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 89 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 90 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 91 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 92 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 93 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 94 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 95 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 96 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 97 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 98 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= 99 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 100 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 101 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 102 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 103 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 104 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 105 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 106 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 107 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 110 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 111 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 112 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 113 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 114 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 115 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 116 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 117 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 118 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 119 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 120 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 121 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 122 | google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= 123 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 124 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 125 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 126 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 127 | google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= 128 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 129 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 130 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 131 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 132 | google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= 133 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 134 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 135 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 136 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 137 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 138 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 139 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 140 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 141 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 142 | google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= 143 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 144 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 145 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 146 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 147 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 148 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 149 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 150 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 151 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 152 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 153 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 154 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 155 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 156 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 157 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 158 | -------------------------------------------------------------------------------- /goauth2.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/inklabs/rangedb" 8 | "github.com/inklabs/rangedb/pkg/clock" 9 | "github.com/inklabs/rangedb/pkg/clock/provider/systemclock" 10 | "github.com/inklabs/rangedb/provider/inmemorystore" 11 | 12 | "github.com/inklabs/goauth2/provider/uuidtoken" 13 | ) 14 | 15 | // Version for Go OAuth2. 16 | const Version = "0.1.0-dev" 17 | 18 | // TokenGenerator defines a token generator for refresh tokens and authorization codes. 19 | type TokenGenerator interface { 20 | New() string 21 | } 22 | 23 | // App is the OAuth2 CQRS application. 24 | type App struct { 25 | clock clock.Clock 26 | store rangedb.Store 27 | tokenGenerator TokenGenerator 28 | preCommandHandlers map[string][]PreCommandHandler 29 | commandHandlerFactories map[string]CommandHandlerFactory 30 | logger *log.Logger 31 | } 32 | 33 | // Option defines functional option parameters for App. 34 | type Option func(*App) 35 | 36 | // WithClock is a functional option to inject a clock. 37 | func WithClock(clock clock.Clock) Option { 38 | return func(app *App) { 39 | app.clock = clock 40 | } 41 | } 42 | 43 | // WithStore is a functional option to inject a RangeDB Event Store. 44 | func WithStore(store rangedb.Store) Option { 45 | return func(app *App) { 46 | app.store = store 47 | } 48 | } 49 | 50 | // WithTokenGenerator is a functional option to inject a token generator. 51 | func WithTokenGenerator(generator TokenGenerator) Option { 52 | return func(app *App) { 53 | app.tokenGenerator = generator 54 | } 55 | } 56 | 57 | // WithLogger is a functional option to inject a Logger. 58 | func WithLogger(logger *log.Logger) Option { 59 | return func(app *App) { 60 | app.logger = logger 61 | } 62 | } 63 | 64 | // New constructs an OAuth2 CQRS application. 65 | func New(options ...Option) (*App, error) { 66 | app := &App{ 67 | store: inmemorystore.New(), 68 | tokenGenerator: uuidtoken.NewGenerator(), 69 | clock: systemclock.New(), 70 | commandHandlerFactories: make(map[string]CommandHandlerFactory), 71 | preCommandHandlers: make(map[string][]PreCommandHandler), 72 | } 73 | 74 | for _, option := range options { 75 | option(app) 76 | } 77 | 78 | BindEvents(app.store) 79 | 80 | app.registerPreCommandHandler(newClientApplicationCommandAuthorization(app.store, app.clock)) 81 | app.registerPreCommandHandler(newResourceOwnerCommandAuthorization(app.store, app.tokenGenerator, app.clock)) 82 | 83 | app.registerCommandHandler(ResourceOwnerCommandTypes(), app.newResourceOwnerAggregate) 84 | app.registerCommandHandler(ClientApplicationCommandTypes(), app.newClientApplicationAggregate) 85 | app.registerCommandHandler(AuthorizationCodeCommandTypes(), app.newAuthorizationCodeAggregate) 86 | app.registerCommandHandler(RefreshTokenCommandTypes(), app.newRefreshTokenAggregate) 87 | 88 | authorizationCodeRefreshTokens := NewAuthorizationCodeRefreshTokens() 89 | err := app.SubscribeAndReplay(authorizationCodeRefreshTokens) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | ctx := context.Background() 95 | subscribers := newMultipleSubscriber( 96 | newRefreshTokenProcessManager(app.Dispatch, authorizationCodeRefreshTokens), 97 | newAuthorizationCodeProcessManager(app.Dispatch), 98 | ) 99 | subscriber := app.store.AllEventsSubscription(ctx, 10, subscribers) 100 | err = subscriber.Start() 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | return app, nil 106 | } 107 | 108 | func (a *App) registerPreCommandHandler(handler PreCommandHandler) { 109 | for _, commandType := range handler.CommandTypes() { 110 | a.preCommandHandlers[commandType] = append(a.preCommandHandlers[commandType], handler) 111 | } 112 | } 113 | 114 | func (a *App) registerCommandHandler(commandTypes []string, factory CommandHandlerFactory) { 115 | for _, commandType := range commandTypes { 116 | a.commandHandlerFactories[commandType] = factory 117 | } 118 | } 119 | 120 | // Dispatch dispatches a command returning all persisted rangedb.Event's. 121 | func (a *App) Dispatch(command Command) []rangedb.Event { 122 | var preHandlerEvents []rangedb.Event 123 | 124 | preCommandHandlers, ok := a.preCommandHandlers[command.CommandType()] 125 | if ok { 126 | for _, handler := range preCommandHandlers { 127 | shouldContinue := handler.Handle(command) 128 | preHandlerEvents = append(preHandlerEvents, a.savePendingEvents(handler)...) 129 | 130 | if !shouldContinue { 131 | return preHandlerEvents 132 | } 133 | } 134 | } 135 | 136 | newCommandHandler, ok := a.commandHandlerFactories[command.CommandType()] 137 | if !ok { 138 | a.logger.Printf("command handler not found") 139 | return preHandlerEvents 140 | } 141 | 142 | handler := newCommandHandler(command) 143 | handler.Handle(command) 144 | handlerEvents := a.savePendingEvents(handler) 145 | 146 | return append(preHandlerEvents, handlerEvents...) 147 | } 148 | 149 | func (a *App) newClientApplicationAggregate(command Command) CommandHandler { 150 | return newClientApplication(a.eventsByStream(rangedb.GetEventStream(command)), a.clock) 151 | } 152 | 153 | func (a *App) newResourceOwnerAggregate(command Command) CommandHandler { 154 | return newResourceOwner( 155 | a.eventsByStream(rangedb.GetEventStream(command)), 156 | a.tokenGenerator, 157 | a.clock, 158 | ) 159 | } 160 | 161 | func (a *App) newAuthorizationCodeAggregate(command Command) CommandHandler { 162 | return newAuthorizationCode( 163 | a.eventsByStream(rangedb.GetEventStream(command)), 164 | a.tokenGenerator, 165 | a.clock, 166 | ) 167 | } 168 | 169 | func (a *App) newRefreshTokenAggregate(command Command) CommandHandler { 170 | return newRefreshToken( 171 | a.eventsByStream(rangedb.GetEventStream(command)), 172 | a.tokenGenerator, 173 | a.clock, 174 | ) 175 | } 176 | 177 | func (a *App) eventsByStream(streamName string) rangedb.RecordIterator { 178 | return a.store.EventsByStream(context.Background(), 0, streamName) 179 | } 180 | 181 | func (a *App) savePendingEvents(events PendingEvents) []rangedb.Event { 182 | pendingEvents := events.GetPendingEvents() 183 | ctx := context.Background() 184 | for _, event := range pendingEvents { 185 | _, err := a.store.Save(ctx, &rangedb.EventRecord{ 186 | Event: event, 187 | }) 188 | if err != nil { 189 | a.logger.Printf("unable to save event: %v", err) 190 | } 191 | } 192 | return pendingEvents 193 | } 194 | 195 | // SubscribeAndReplay subscribes and replays all events starting with zero. 196 | func (a *App) SubscribeAndReplay(subscribers ...rangedb.RecordSubscriber) error { 197 | ctx := context.Background() 198 | subscription := a.store.AllEventsSubscription(ctx, 50, newMultipleSubscriber(subscribers...)) 199 | err := subscription.StartFrom(0) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | return nil 205 | } 206 | 207 | func resourceOwnerStream(userID string) string { 208 | return rangedb.GetEventStream(UserWasOnBoarded{UserID: userID}) 209 | } 210 | 211 | func clientApplicationStream(clientID string) string { 212 | return rangedb.GetEventStream(ClientApplicationWasOnBoarded{ClientID: clientID}) 213 | } 214 | 215 | type multipleSubscriber struct { 216 | subscribers []rangedb.RecordSubscriber 217 | } 218 | 219 | func newMultipleSubscriber(subscribers ...rangedb.RecordSubscriber) *multipleSubscriber { 220 | return &multipleSubscriber{ 221 | subscribers: subscribers, 222 | } 223 | } 224 | 225 | // Accept receives a rangedb.Record. 226 | func (m multipleSubscriber) Accept(record *rangedb.Record) { 227 | for _, subscriber := range m.subscribers { 228 | subscriber.Accept(record) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /goauth2test/seeded_token_generator.go: -------------------------------------------------------------------------------- 1 | package goauth2test 2 | 3 | type seededTokenGenerator struct { 4 | codes []string 5 | index int 6 | } 7 | 8 | // NewSeededTokenGenerator constructs a seededTokenGenerator 9 | func NewSeededTokenGenerator(codes ...string) *seededTokenGenerator { 10 | return &seededTokenGenerator{codes: codes} 11 | } 12 | 13 | func (s *seededTokenGenerator) New() string { 14 | index := s.index 15 | s.index++ 16 | return s.codes[index] 17 | } 18 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | package goauth2_test 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/inklabs/rangedb/provider/inmemorystore" 8 | "github.com/inklabs/rangedb/rangedbtest/bdd" 9 | 10 | "github.com/inklabs/goauth2" 11 | ) 12 | 13 | func goauth2TestCase(options ...goauth2.Option) *bdd.TestCase { 14 | store := inmemorystore.New() 15 | goauth2.BindEvents(store) 16 | options = append([]goauth2.Option{goauth2.WithStore(store)}, options...) 17 | 18 | return bdd.New(store, func(command bdd.Command) { 19 | app, err := goauth2.New(options...) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | app.Dispatch(command) 25 | waitForProjections() 26 | }) 27 | } 28 | 29 | func waitForProjections() { 30 | time.Sleep(10 * time.Millisecond) 31 | } 32 | -------------------------------------------------------------------------------- /passwords.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | ) 6 | 7 | // GeneratePasswordHash returns a password using bcrypt.GenerateFromPassword. 8 | func GeneratePasswordHash(password string) string { 9 | hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 10 | 11 | return string(hash) 12 | } 13 | 14 | // VerifyPassword verifies a password using bcrypt.CompareHashAndPassword. 15 | func VerifyPassword(hash string, password string) bool { 16 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 17 | if err != nil { 18 | return false 19 | } 20 | 21 | return true 22 | } 23 | -------------------------------------------------------------------------------- /passwords_test.go: -------------------------------------------------------------------------------- 1 | package goauth2_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/inklabs/goauth2" 9 | ) 10 | 11 | func Test_GeneratePasswordHash(t *testing.T) { 12 | // Given 13 | password := "test123!" 14 | 15 | // When 16 | hash := goauth2.GeneratePasswordHash(password) 17 | 18 | // Then 19 | isValid := goauth2.VerifyPassword(hash, password) 20 | assert.True(t, isValid) 21 | } 22 | 23 | func Test_VerifyPassword(t *testing.T) { 24 | // Given 25 | hash := "$2a$10$kXoIYjFFopkb5hGWTdFum.wuse7u8vyhq/5cJoyqbA9rI1cfR/ow6" 26 | password := "test123!" 27 | 28 | // When 29 | isValid := goauth2.VerifyPassword(hash, password) 30 | 31 | // Then 32 | assert.True(t, isValid) 33 | } 34 | 35 | func Test_VerifyPasswordFails(t *testing.T) { 36 | // Given 37 | hash := "$2a$10$kXoIYjFFopkb5hGWTdFum.wuse7u8vyhq/5cJoyqbA9rI1cfR/ow6" 38 | password := "wrong-password" 39 | 40 | // When 41 | isValid := goauth2.VerifyPassword(hash, password) 42 | 43 | // Then 44 | assert.False(t, isValid) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/securepass/securepass.go: -------------------------------------------------------------------------------- 1 | package securepass 2 | 3 | var insecurePasswords = []string{"password", "pass123", "password123", "1234"} 4 | 5 | // IsInsecure returns whether a password is insecure. 6 | func IsInsecure(password string) bool { 7 | return isStringInSlice(insecurePasswords, password) 8 | } 9 | 10 | func isStringInSlice(a []string, x string) bool { 11 | for _, n := range a { 12 | if x == n { 13 | return true 14 | } 15 | } 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /projection/client_applications.go: -------------------------------------------------------------------------------- 1 | package projection 2 | 3 | import ( 4 | "sort" 5 | "sync" 6 | 7 | "github.com/inklabs/rangedb" 8 | 9 | "github.com/inklabs/goauth2" 10 | ) 11 | 12 | type clientApplication struct { 13 | ClientID string 14 | ClientSecret string 15 | CreateTimestamp uint64 16 | } 17 | 18 | // ClientApplications is a projection containing a list of all client applications. 19 | type ClientApplications struct { 20 | mu sync.RWMutex 21 | clientApplications map[string]*clientApplication 22 | } 23 | 24 | // NewClientApplications constructs a new ClientApplications projection. 25 | func NewClientApplications() *ClientApplications { 26 | return &ClientApplications{ 27 | clientApplications: make(map[string]*clientApplication), 28 | } 29 | } 30 | 31 | // Accept receives a rangedb.Record. 32 | func (a *ClientApplications) Accept(record *rangedb.Record) { 33 | event, ok := record.Data.(*goauth2.ClientApplicationWasOnBoarded) 34 | if ok { 35 | a.mu.Lock() 36 | defer a.mu.Unlock() 37 | 38 | a.clientApplications[event.ClientID] = &clientApplication{ 39 | ClientID: event.ClientID, 40 | ClientSecret: event.ClientSecret, 41 | CreateTimestamp: record.InsertTimestamp, 42 | } 43 | } 44 | } 45 | 46 | // GetAll returns client applications sorted by most recent creation timestamp 47 | func (a *ClientApplications) GetAll() []*clientApplication { 48 | a.mu.RLock() 49 | 50 | var clientApplications []*clientApplication 51 | for _, clientApplication := range a.clientApplications { 52 | clientApplications = append(clientApplications, clientApplication) 53 | } 54 | 55 | a.mu.RUnlock() 56 | 57 | sort.SliceStable(clientApplications, func(i, j int) bool { 58 | return clientApplications[i].CreateTimestamp >= clientApplications[j].CreateTimestamp 59 | }) 60 | 61 | return clientApplications 62 | } 63 | -------------------------------------------------------------------------------- /projection/client_applications_test.go: -------------------------------------------------------------------------------- 1 | package projection_test 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | 8 | "github.com/inklabs/rangedb/rangedbtest" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/inklabs/goauth2" 12 | "github.com/inklabs/goauth2/projection" 13 | ) 14 | 15 | func TestClientApplications_Accept(t *testing.T) { 16 | // Given 17 | const ( 18 | clientID = "f9236197e7f24ef994cbe2e06e026f24" 19 | clientSecret = "5970aca5e64d4f5e9e7842db8796619f" 20 | userID = "e171f450626644fa8656b037c42bbf11" 21 | redirectURI = "http://example.com/oauth2/callback" 22 | clientID2 = "0e3c58dd233b43c0aba4fa7578b9aba0" 23 | ) 24 | issueTime := time.Date(2020, 05, 11, 8, 0, 0, 0, time.UTC) 25 | 26 | t.Run("can get all client applications", func(t *testing.T) { 27 | // Given 28 | clientApplications := projection.NewClientApplications() 29 | record := rangedbtest.DummyRecordFromEvent(&goauth2.ClientApplicationWasOnBoarded{ 30 | ClientID: clientID, 31 | ClientSecret: clientSecret, 32 | RedirectURI: redirectURI, 33 | UserID: userID, 34 | }) 35 | record.InsertTimestamp = uint64(issueTime.Unix()) 36 | clientApplications.Accept(record) 37 | 38 | // When 39 | actualClientApplications := clientApplications.GetAll() 40 | 41 | // Then 42 | assert.Len(t, actualClientApplications, 1) 43 | assert.Equal(t, clientID, actualClientApplications[0].ClientID) 44 | assert.Equal(t, clientSecret, actualClientApplications[0].ClientSecret) 45 | assert.Equal(t, uint64(issueTime.Unix()), actualClientApplications[0].CreateTimestamp) 46 | }) 47 | 48 | t.Run("returns empty list", func(t *testing.T) { 49 | // Given 50 | clientApplications := projection.NewClientApplications() 51 | 52 | // When 53 | actualClientApplications := clientApplications.GetAll() 54 | 55 | // Then 56 | assert.Len(t, actualClientApplications, 0) 57 | }) 58 | 59 | t.Run("does not error from deadlock", func(t *testing.T) { 60 | // Given 61 | clientApplications := projection.NewClientApplications() 62 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.ClientApplicationWasOnBoarded{ 63 | ClientID: clientID, 64 | ClientSecret: clientSecret, 65 | RedirectURI: redirectURI, 66 | UserID: userID, 67 | }) 68 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.ClientApplicationWasOnBoarded{ 69 | ClientID: clientID2, 70 | ClientSecret: clientSecret, 71 | RedirectURI: redirectURI, 72 | UserID: userID, 73 | }) 74 | var wg sync.WaitGroup 75 | wg.Add(2) 76 | 77 | // When 78 | go func() { 79 | clientApplications.Accept(record1) 80 | wg.Done() 81 | }() 82 | clientApplications.Accept(record2) 83 | wg.Done() 84 | 85 | // Then 86 | wg.Wait() 87 | actualClientApplications := clientApplications.GetAll() 88 | assert.Len(t, actualClientApplications, 2) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /projection/email_to_userid.go: -------------------------------------------------------------------------------- 1 | package projection 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/inklabs/rangedb" 8 | 9 | "github.com/inklabs/goauth2" 10 | ) 11 | 12 | // EmailToUserID projection. 13 | type EmailToUserID struct { 14 | mu sync.RWMutex 15 | emailToUserID map[string]string 16 | } 17 | 18 | // NewEmailToUserID constructs an EmailToUserID projection. 19 | func NewEmailToUserID() *EmailToUserID { 20 | return &EmailToUserID{ 21 | emailToUserID: make(map[string]string), 22 | } 23 | } 24 | 25 | // Accept receives a rangedb.Record. 26 | func (a *EmailToUserID) Accept(record *rangedb.Record) { 27 | event, ok := record.Data.(*goauth2.UserWasOnBoarded) 28 | if ok { 29 | a.mu.Lock() 30 | defer a.mu.Unlock() 31 | 32 | a.emailToUserID[event.Username] = event.UserID 33 | } 34 | } 35 | 36 | // GetUserID returns a userID by email or ErrUserNotFound. 37 | func (a *EmailToUserID) GetUserID(email string) (string, error) { 38 | a.mu.RLock() 39 | defer a.mu.RUnlock() 40 | 41 | userID, ok := a.emailToUserID[email] 42 | if !ok { 43 | return "", ErrUserNotFound 44 | } 45 | 46 | return userID, nil 47 | } 48 | 49 | // ErrUserNotFound is a defined error for missing user. 50 | var ErrUserNotFound = fmt.Errorf("user not found") 51 | -------------------------------------------------------------------------------- /projection/email_to_userid_test.go: -------------------------------------------------------------------------------- 1 | package projection_test 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/inklabs/rangedb/rangedbtest" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/inklabs/goauth2" 12 | "github.com/inklabs/goauth2/projection" 13 | ) 14 | 15 | func TestEmailToUserID_Accept(t *testing.T) { 16 | // Given 17 | const ( 18 | userID = "881d60f1905d4457a611d596ae55d964" 19 | userID2 = "e0d0f5d7a72b432e8d553a0ac5c3d9b1" 20 | email = "john@example.com" 21 | ) 22 | 23 | t.Run("can get userID from email", func(t *testing.T) { 24 | // Given 25 | emailToUserID := projection.NewEmailToUserID() 26 | emailToUserID.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 27 | UserID: userID, 28 | Username: email, 29 | })) 30 | 31 | // When 32 | actualUserID, err := emailToUserID.GetUserID(email) 33 | 34 | // Then 35 | require.NoError(t, err) 36 | assert.Equal(t, userID, actualUserID) 37 | }) 38 | 39 | t.Run("returns error for missing email", func(t *testing.T) { 40 | // Given 41 | emailToUserID := projection.NewEmailToUserID() 42 | emailToUserID.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 43 | UserID: userID, 44 | Username: email, 45 | })) 46 | 47 | // When 48 | actualUserID, err := emailToUserID.GetUserID("wrong-email@example.com") 49 | 50 | // Then 51 | assert.Equal(t, "", actualUserID) 52 | assert.Equal(t, err, projection.ErrUserNotFound) 53 | }) 54 | 55 | t.Run("can get userID from email with duplicate email", func(t *testing.T) { 56 | // Given 57 | emailToUserID := projection.NewEmailToUserID() 58 | emailToUserID.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 59 | UserID: userID, 60 | Username: email, 61 | })) 62 | emailToUserID.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 63 | UserID: userID2, 64 | Username: email, 65 | })) 66 | 67 | // When 68 | actualUserID, err := emailToUserID.GetUserID(email) 69 | 70 | // Then 71 | require.NoError(t, err) 72 | assert.Equal(t, userID2, actualUserID) 73 | }) 74 | 75 | t.Run("does not error from deadlock", func(t *testing.T) { 76 | // Given 77 | emailToUserID := projection.NewEmailToUserID() 78 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 79 | UserID: userID, 80 | Username: email, 81 | }) 82 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 83 | UserID: userID2, 84 | Username: email, 85 | }) 86 | var wg sync.WaitGroup 87 | wg.Add(2) 88 | 89 | // When 90 | go func() { 91 | emailToUserID.Accept(record1) 92 | wg.Done() 93 | }() 94 | emailToUserID.Accept(record2) 95 | wg.Done() 96 | 97 | // Then 98 | wg.Wait() 99 | actualUserID, err := emailToUserID.GetUserID(email) 100 | require.NoError(t, err) 101 | assert.Equal(t, userID, actualUserID) 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /projection/users.go: -------------------------------------------------------------------------------- 1 | package projection 2 | 3 | import ( 4 | "sort" 5 | "sync" 6 | 7 | "github.com/inklabs/rangedb" 8 | 9 | "github.com/inklabs/goauth2" 10 | ) 11 | 12 | type user struct { 13 | UserID string 14 | Username string 15 | GrantingUserID string 16 | CreateTimestamp uint64 17 | IsAdmin bool 18 | CanOnboardAdminApplications bool 19 | } 20 | 21 | // Users is a projection containing a list of all users. 22 | type Users struct { 23 | mu sync.RWMutex 24 | users map[string]*user 25 | } 26 | 27 | // NewUsers constructs a new Users projection. 28 | func NewUsers() *Users { 29 | return &Users{ 30 | users: make(map[string]*user), 31 | } 32 | } 33 | 34 | // Accept receives a rangedb.Record. 35 | func (a *Users) Accept(record *rangedb.Record) { 36 | a.mu.Lock() 37 | defer a.mu.Unlock() 38 | 39 | switch event := record.Data.(type) { 40 | 41 | case *goauth2.UserWasOnBoarded: 42 | a.users[event.UserID] = &user{ 43 | UserID: event.UserID, 44 | Username: event.Username, 45 | GrantingUserID: event.GrantingUserID, 46 | CreateTimestamp: record.InsertTimestamp, 47 | } 48 | 49 | case *goauth2.UserWasGrantedAdministratorRole: 50 | if a.userExists(event.UserID) { 51 | a.users[event.UserID].IsAdmin = true 52 | } 53 | 54 | case *goauth2.UserWasAuthorizedToOnBoardClientApplications: 55 | if a.userExists(event.UserID) { 56 | a.users[event.UserID].CanOnboardAdminApplications = true 57 | } 58 | 59 | } 60 | } 61 | 62 | // GetAll returns users sorted by most recent creation timestamp. 63 | func (a *Users) GetAll() []*user { 64 | a.mu.RLock() 65 | 66 | var users []*user 67 | for _, user := range a.users { 68 | users = append(users, user) 69 | } 70 | 71 | a.mu.RUnlock() 72 | 73 | sort.SliceStable(users, func(i, j int) bool { 74 | if users[i].CreateTimestamp == users[j].CreateTimestamp { 75 | return users[i].UserID < users[j].UserID 76 | } 77 | 78 | return users[i].CreateTimestamp >= users[j].CreateTimestamp 79 | }) 80 | 81 | return users 82 | } 83 | 84 | // Get returns a user by userID if found. 85 | func (a *Users) Get(userID string) (*user, error) { 86 | a.mu.RLock() 87 | defer a.mu.RUnlock() 88 | 89 | if u, ok := a.users[userID]; ok { 90 | return u, nil 91 | } 92 | 93 | return nil, ErrUserNotFound 94 | } 95 | 96 | func (a *Users) userExists(userID string) bool { 97 | _, ok := a.users[userID] 98 | return ok 99 | } 100 | -------------------------------------------------------------------------------- /projection/users_test.go: -------------------------------------------------------------------------------- 1 | package projection_test 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | 8 | "github.com/inklabs/rangedb/rangedbtest" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/inklabs/goauth2" 13 | "github.com/inklabs/goauth2/projection" 14 | ) 15 | 16 | func TestUsers_Accept(t *testing.T) { 17 | // Given 18 | const ( 19 | userID = "e57252d9b7d6432bb089bc5cd86d12b3" 20 | username = "john123" 21 | userID2 = "b1e9086e05bb43ceaf7f3173b76569e3" 22 | username2 = "jane456" 23 | passwordHash = "$2a$10$U6ej0p2d9Y8OO2635R7l/O4oEBvxgc9o6gCaQ1wjMZ77dr4qGl8nu" 24 | adminUserID = "b8d9d4bf549d42a08ee3a730c983ae87" 25 | ) 26 | issueTime := time.Date(2020, 05, 11, 8, 0, 0, 0, time.UTC) 27 | issueTime2 := time.Date(2020, 05, 12, 8, 0, 0, 0, time.UTC) 28 | 29 | t.Run("can get all users ordered by descending creation timestamp", func(t *testing.T) { 30 | // Given 31 | users := projection.NewUsers() 32 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 33 | UserID: userID, 34 | Username: username, 35 | PasswordHash: passwordHash, 36 | }) 37 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 38 | UserID: userID2, 39 | Username: username2, 40 | PasswordHash: passwordHash, 41 | }) 42 | record1.InsertTimestamp = uint64(issueTime.Unix()) 43 | record2.InsertTimestamp = uint64(issueTime2.Unix()) 44 | users.Accept(record2) 45 | users.Accept(record1) 46 | 47 | // When 48 | actualUsers := users.GetAll() 49 | 50 | // Then 51 | assert.Len(t, actualUsers, 2) 52 | assert.Equal(t, userID2, actualUsers[0].UserID) 53 | assert.Equal(t, username2, actualUsers[0].Username) 54 | assert.Equal(t, uint64(issueTime2.Unix()), actualUsers[0].CreateTimestamp) 55 | }) 56 | 57 | t.Run("can get all users ordered by creation timestamp, then by ascending userID", func(t *testing.T) { 58 | // Given 59 | users := projection.NewUsers() 60 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 61 | UserID: userID, 62 | Username: username, 63 | PasswordHash: passwordHash, 64 | }) 65 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 66 | UserID: userID2, 67 | Username: username2, 68 | PasswordHash: passwordHash, 69 | }) 70 | users.Accept(record2) 71 | users.Accept(record1) 72 | 73 | // When 74 | actualUsers := users.GetAll() 75 | 76 | // Then 77 | assert.Len(t, actualUsers, 2) 78 | assert.Equal(t, userID2, actualUsers[0].UserID) 79 | assert.Equal(t, username2, actualUsers[0].Username) 80 | }) 81 | 82 | t.Run("includes admin flag", func(t *testing.T) { 83 | // Given 84 | users := projection.NewUsers() 85 | users.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 86 | UserID: userID, 87 | Username: username, 88 | PasswordHash: passwordHash, 89 | })) 90 | users.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasGrantedAdministratorRole{ 91 | UserID: userID, 92 | GrantingUserID: adminUserID, 93 | })) 94 | 95 | // When 96 | actualUsers := users.GetAll() 97 | 98 | // Then 99 | assert.Len(t, actualUsers, 1) 100 | assert.Equal(t, userID, actualUsers[0].UserID) 101 | assert.True(t, actualUsers[0].IsAdmin) 102 | }) 103 | 104 | t.Run("includes authorized to onboard client applications flag", func(t *testing.T) { 105 | // Given 106 | users := projection.NewUsers() 107 | users.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 108 | UserID: userID, 109 | Username: username, 110 | PasswordHash: passwordHash, 111 | })) 112 | users.Accept(rangedbtest.DummyRecordFromEvent(&goauth2.UserWasAuthorizedToOnBoardClientApplications{ 113 | UserID: userID, 114 | AuthorizingUserID: adminUserID, 115 | })) 116 | 117 | // When 118 | actualUsers := users.GetAll() 119 | 120 | // Then 121 | assert.Len(t, actualUsers, 1) 122 | assert.Equal(t, userID, actualUsers[0].UserID) 123 | assert.True(t, actualUsers[0].CanOnboardAdminApplications) 124 | }) 125 | 126 | t.Run("returns empty list", func(t *testing.T) { 127 | // Given 128 | users := projection.NewUsers() 129 | 130 | // When 131 | actualUsers := users.GetAll() 132 | 133 | // Then 134 | assert.Len(t, actualUsers, 0) 135 | }) 136 | 137 | t.Run("does not error from deadlock", func(t *testing.T) { 138 | // Given 139 | users := projection.NewUsers() 140 | record1 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 141 | UserID: userID, 142 | Username: username, 143 | PasswordHash: passwordHash, 144 | }) 145 | record2 := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 146 | UserID: userID2, 147 | Username: username2, 148 | PasswordHash: passwordHash, 149 | }) 150 | var wg sync.WaitGroup 151 | wg.Add(2) 152 | 153 | // When 154 | go func() { 155 | users.Accept(record1) 156 | wg.Done() 157 | }() 158 | users.Accept(record2) 159 | wg.Done() 160 | 161 | // Then 162 | wg.Wait() 163 | actualUsers := users.GetAll() 164 | assert.Len(t, actualUsers, 2) 165 | }) 166 | 167 | t.Run("Get", func(t *testing.T) { 168 | t.Run("returns user by userID", func(t *testing.T) { 169 | // Given 170 | users := projection.NewUsers() 171 | record := rangedbtest.DummyRecordFromEvent(&goauth2.UserWasOnBoarded{ 172 | UserID: userID, 173 | Username: username, 174 | PasswordHash: passwordHash, 175 | GrantingUserID: adminUserID, 176 | }) 177 | users.Accept(record) 178 | 179 | // When 180 | actualUser, err := users.Get(userID) 181 | 182 | // Then 183 | require.NoError(t, err) 184 | assert.Equal(t, userID, actualUser.UserID) 185 | assert.Equal(t, username, actualUser.Username) 186 | assert.Equal(t, adminUserID, actualUser.GrantingUserID) 187 | assert.False(t, actualUser.IsAdmin) 188 | assert.False(t, actualUser.CanOnboardAdminApplications) 189 | }) 190 | 191 | t.Run("returns user not found", func(t *testing.T) { 192 | // Given 193 | const notFoundID = "af5aa3e15b2a47aca0f5af0e7437ce3f" 194 | users := projection.NewUsers() 195 | 196 | // When 197 | actualUser, err := users.Get(notFoundID) 198 | 199 | // Then 200 | assert.Nil(t, actualUser) 201 | assert.Equal(t, projection.ErrUserNotFound, err) 202 | }) 203 | }) 204 | } 205 | -------------------------------------------------------------------------------- /provider/uuidtoken/uuid_token_generator.go: -------------------------------------------------------------------------------- 1 | package uuidtoken 2 | 3 | import ( 4 | "github.com/inklabs/rangedb/pkg/shortuuid" 5 | ) 6 | 7 | type uuidTokenGenerator struct{} 8 | 9 | // NewGenerator constructs a new uuidTokenGenerator. 10 | func NewGenerator() *uuidTokenGenerator { 11 | return &uuidTokenGenerator{} 12 | } 13 | 14 | func (u *uuidTokenGenerator) New() string { 15 | return shortuuid.New().String() 16 | } 17 | -------------------------------------------------------------------------------- /provider/uuidtoken/uuid_token_generator_test.go: -------------------------------------------------------------------------------- 1 | package uuidtoken_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/google/uuid" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/inklabs/goauth2/provider/uuidtoken" 12 | ) 13 | 14 | func Test_Generator_ReturnsHexTokenWithoutHyphens(t *testing.T) { 15 | // Given 16 | generator := uuidtoken.NewGenerator() 17 | 18 | // When 19 | token := generator.New() 20 | 21 | // Then 22 | assert.Equal(t, 32, len(token)) 23 | assert.NotContains(t, token, "-") 24 | } 25 | 26 | func Test_Generator_ReturnsValidUUIDToken(t *testing.T) { 27 | // Given 28 | generator := uuidtoken.NewGenerator() 29 | 30 | // When 31 | token := generator.New() 32 | 33 | // Then 34 | u, err := uuid.Parse(token) 35 | require.NoError(t, err) 36 | actualWithoutHyphens := strings.Replace(u.String(), "-", "", -1) 37 | assert.Equal(t, token, actualWithoutHyphens) 38 | } 39 | -------------------------------------------------------------------------------- /refresh_token.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/clock" 8 | ) 9 | 10 | const refreshTokenGrantLifetime = 1 * time.Hour 11 | 12 | // RefreshTokenCommandTypes returns all command types goauth2.refreshToken supports. 13 | func RefreshTokenCommandTypes() []string { 14 | return []string{ 15 | RequestAccessTokenViaRefreshTokenGrant{}.CommandType(), 16 | IssueRefreshTokenToUser{}.CommandType(), 17 | RevokeRefreshTokenFromUser{}.CommandType(), 18 | } 19 | } 20 | 21 | type refreshToken struct { 22 | tokenGenerator TokenGenerator 23 | clock clock.Clock 24 | Token string 25 | Scope string 26 | PendingEvents []rangedb.Event 27 | Username string 28 | IsLoaded bool 29 | HasBeenPreviouslyUsed bool 30 | HasBeenRevoked bool 31 | UserID string 32 | ClientID string 33 | } 34 | 35 | func newRefreshToken(iter rangedb.RecordIterator, generator TokenGenerator, clock clock.Clock) *refreshToken { 36 | aggregate := &refreshToken{ 37 | tokenGenerator: generator, 38 | clock: clock, 39 | } 40 | 41 | for iter.Next() { 42 | if event, ok := iter.Record().Data.(rangedb.Event); ok { 43 | aggregate.apply(event) 44 | } 45 | } 46 | 47 | return aggregate 48 | } 49 | 50 | func (a *refreshToken) apply(event rangedb.Event) { 51 | switch e := event.(type) { 52 | 53 | case *RefreshTokenWasIssuedToUser: 54 | a.IsLoaded = true 55 | a.UserID = e.UserID 56 | a.Scope = e.Scope 57 | 58 | case *RefreshTokenWasIssuedToUserViaRefreshTokenGrant: 59 | a.HasBeenPreviouslyUsed = true 60 | 61 | case *RefreshTokenWasRevokedFromUser: 62 | a.HasBeenRevoked = true 63 | 64 | } 65 | } 66 | 67 | func (a *refreshToken) Handle(command Command) { 68 | switch c := command.(type) { 69 | 70 | case RequestAccessTokenViaRefreshTokenGrant: 71 | a.RequestAccessTokenViaRefreshTokenGrant(c) 72 | 73 | case IssueRefreshTokenToUser: 74 | a.IssueRefreshTokenToUser(c) 75 | 76 | case RevokeRefreshTokenFromUser: 77 | a.RevokeRefreshTokenFromUser(c) 78 | 79 | } 80 | } 81 | 82 | func (a *refreshToken) GetPendingEvents() []rangedb.Event { 83 | return a.PendingEvents 84 | } 85 | 86 | func (a *refreshToken) raise(events ...rangedb.Event) { 87 | for _, event := range events { 88 | a.apply(event) 89 | } 90 | 91 | a.PendingEvents = append(a.PendingEvents, events...) 92 | } 93 | 94 | func (a *refreshToken) RequestAccessTokenViaRefreshTokenGrant(c RequestAccessTokenViaRefreshTokenGrant) { 95 | if !a.IsLoaded { 96 | a.raise(RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToInvalidRefreshToken{ 97 | RefreshToken: c.RefreshToken, 98 | ClientID: c.ClientID, 99 | }) 100 | return 101 | } 102 | 103 | if a.HasBeenPreviouslyUsed { 104 | a.raise(RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToPreviouslyUsedRefreshToken{ 105 | RefreshToken: c.RefreshToken, 106 | }) 107 | return 108 | } 109 | 110 | if a.HasBeenRevoked { 111 | a.raise(RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToRevokedRefreshToken{ 112 | RefreshToken: c.RefreshToken, 113 | ClientID: c.ClientID, 114 | }) 115 | return 116 | } 117 | 118 | if c.Scope != "" && a.Scope != c.Scope { 119 | a.raise(RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToInvalidScope{ 120 | RefreshToken: c.RefreshToken, 121 | ClientID: c.ClientID, 122 | Scope: a.Scope, 123 | RequestedScope: c.Scope, 124 | }) 125 | return 126 | } 127 | 128 | nextRefreshToken := a.tokenGenerator.New() 129 | expiresAt := a.clock.Now().Add(refreshTokenGrantLifetime).Unix() 130 | 131 | a.raise( 132 | AccessTokenWasIssuedToUserViaRefreshTokenGrant{ 133 | RefreshToken: c.RefreshToken, 134 | UserID: a.UserID, 135 | ClientID: c.ClientID, 136 | Scope: c.Scope, 137 | ExpiresAt: expiresAt, 138 | }, 139 | RefreshTokenWasIssuedToUserViaRefreshTokenGrant{ 140 | RefreshToken: c.RefreshToken, 141 | UserID: a.UserID, 142 | ClientID: c.ClientID, 143 | NextRefreshToken: nextRefreshToken, 144 | Scope: c.Scope, 145 | }, 146 | ) 147 | } 148 | 149 | func (a *refreshToken) IssueRefreshTokenToUser(c IssueRefreshTokenToUser) { 150 | a.raise(RefreshTokenWasIssuedToUser{ 151 | RefreshToken: c.RefreshToken, 152 | UserID: c.UserID, 153 | ClientID: c.ClientID, 154 | Scope: c.Scope, 155 | }) 156 | } 157 | 158 | func (a *refreshToken) RevokeRefreshTokenFromUser(c RevokeRefreshTokenFromUser) { 159 | a.raise(RefreshTokenWasRevokedFromUser{ 160 | RefreshToken: c.RefreshToken, 161 | ClientID: c.ClientID, 162 | UserID: c.UserID, 163 | }) 164 | } 165 | -------------------------------------------------------------------------------- /refresh_token_commands.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/commandgenerator -id RefreshToken -aggregateType refresh-token 4 | 5 | type RequestAccessTokenViaRefreshTokenGrant struct { 6 | RefreshToken string `json:"refreshToken"` 7 | ClientID string `json:"clientID"` 8 | ClientSecret string `json:"clientSecret"` 9 | Scope string `json:"scope"` 10 | } 11 | type IssueRefreshTokenToUser struct { 12 | RefreshToken string `json:"refreshToken"` 13 | UserID string `json:"userID"` 14 | ClientID string `json:"clientID"` 15 | Scope string `json:"scope"` 16 | } 17 | type RevokeRefreshTokenFromUser struct { 18 | RefreshToken string `json:"refreshToken"` 19 | UserID string `json:"userID"` 20 | ClientID string `json:"clientID"` 21 | } 22 | -------------------------------------------------------------------------------- /refresh_token_events.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/eventgenerator -id RefreshToken -aggregateType refresh-token 4 | 5 | // RequestAccessTokenViaRefreshTokenGrant Events 6 | 7 | type RefreshTokenWasIssuedToUser struct { 8 | RefreshToken string `json:"refreshToken"` 9 | UserID string `json:"userID"` 10 | ClientID string `json:"clientID"` 11 | Scope string `json:"scope"` 12 | } 13 | type RefreshTokenWasRevokedFromUser struct { 14 | RefreshToken string `json:"refreshToken"` 15 | UserID string `json:"userID"` 16 | ClientID string `json:"clientID"` 17 | } 18 | type AccessTokenWasIssuedToUserViaRefreshTokenGrant struct { 19 | RefreshToken string `json:"refreshToken"` 20 | UserID string `json:"userID"` 21 | ClientID string `json:"clientID"` 22 | Scope string `json:"scope"` 23 | ExpiresAt int64 `json:"expiresAt"` 24 | } 25 | type RefreshTokenWasIssuedToUserViaRefreshTokenGrant struct { 26 | RefreshToken string `json:"refreshToken"` 27 | UserID string `json:"userID"` 28 | ClientID string `json:"clientID"` 29 | NextRefreshToken string `json:"nextRefreshToken"` 30 | Scope string `json:"scope"` 31 | } 32 | type RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToInvalidRefreshToken struct { 33 | RefreshToken string `json:"refreshToken"` 34 | ClientID string `json:"clientID"` 35 | } 36 | type RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToInvalidScope struct { 37 | RefreshToken string `json:"refreshToken"` 38 | ClientID string `json:"clientID"` 39 | Scope string `json:"scope"` 40 | RequestedScope string `json:"requestedScope"` 41 | } 42 | type RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToInvalidClientApplicationCredentials struct { 43 | RefreshToken string `json:"refreshToken"` 44 | ClientID string `json:"clientID"` 45 | } 46 | type RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToPreviouslyUsedRefreshToken struct { 47 | RefreshToken string `json:"refreshToken"` 48 | } 49 | type RequestAccessTokenViaRefreshTokenGrantWasRejectedDueToRevokedRefreshToken struct { 50 | RefreshToken string `json:"refreshToken"` 51 | ClientID string `json:"clientID"` 52 | } 53 | type AccessTokenWasRevokedDueToPreviouslyUsedRefreshToken struct { 54 | RefreshToken string `json:"refreshToken"` 55 | } 56 | -------------------------------------------------------------------------------- /refresh_token_process_manager.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "github.com/inklabs/rangedb" 5 | ) 6 | 7 | type refreshTokenProcessManager struct { 8 | dispatch CommandDispatcher 9 | authorizationCodeRefreshtokens *AuthorizationCodeRefreshTokens 10 | } 11 | 12 | func newRefreshTokenProcessManager( 13 | commandDispatcher CommandDispatcher, 14 | authorizationCodeRefreshTokens *AuthorizationCodeRefreshTokens, 15 | ) *refreshTokenProcessManager { 16 | return &refreshTokenProcessManager{ 17 | dispatch: commandDispatcher, 18 | authorizationCodeRefreshtokens: authorizationCodeRefreshTokens, 19 | } 20 | } 21 | 22 | // Accept receives a rangedb.Record. 23 | func (r *refreshTokenProcessManager) Accept(record *rangedb.Record) { 24 | switch event := record.Data.(type) { 25 | 26 | case *RefreshTokenWasIssuedToUserViaROPCGrant: 27 | r.dispatch(IssueRefreshTokenToUser{ 28 | RefreshToken: event.RefreshToken, 29 | UserID: event.UserID, 30 | ClientID: event.ClientID, 31 | Scope: event.Scope, 32 | }) 33 | 34 | case *RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant: 35 | r.dispatch(IssueRefreshTokenToUser{ 36 | RefreshToken: event.RefreshToken, 37 | UserID: event.UserID, 38 | ClientID: event.ClientID, 39 | Scope: event.Scope, 40 | }) 41 | 42 | case *RefreshTokenWasIssuedToUserViaRefreshTokenGrant: 43 | r.dispatch(IssueRefreshTokenToUser{ 44 | RefreshToken: event.NextRefreshToken, 45 | UserID: event.UserID, 46 | ClientID: event.ClientID, 47 | Scope: event.Scope, 48 | }) 49 | 50 | case *RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToPreviouslyUsedAuthorizationCode: 51 | for _, refreshToken := range r.authorizationCodeRefreshtokens.GetTokens(event.AuthorizationCode) { 52 | r.dispatch(RevokeRefreshTokenFromUser{ 53 | RefreshToken: refreshToken, 54 | UserID: event.UserID, 55 | ClientID: event.ClientID, 56 | }) 57 | } 58 | 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /resource_owner.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/clock" 8 | 9 | "github.com/inklabs/goauth2/pkg/securepass" 10 | ) 11 | 12 | const ( 13 | authorizationCodeLifetime = 10 * time.Minute 14 | authorizationCodeGrantLifetime = 1 * time.Hour 15 | ropcGrantLifetime = 1 * time.Hour 16 | ) 17 | 18 | // ResourceOwnerCommandTypes returns all command types goauth2.resourceOwner supports. 19 | func ResourceOwnerCommandTypes() []string { 20 | return []string{ 21 | GrantUserAdministratorRole{}.CommandType(), 22 | OnBoardUser{}.CommandType(), 23 | AuthorizeUserToOnBoardClientApplications{}.CommandType(), 24 | RequestAccessTokenViaImplicitGrant{}.CommandType(), 25 | RequestAccessTokenViaROPCGrant{}.CommandType(), 26 | RequestAuthorizationCodeViaAuthorizationCodeGrant{}.CommandType(), 27 | } 28 | } 29 | 30 | type resourceOwner struct { 31 | IsOnBoarded bool 32 | Username string 33 | PasswordHash string 34 | PendingEvents []rangedb.Event 35 | IsAdministrator bool 36 | IsAuthorizedToOnboardClientApplications bool 37 | tokenGenerator TokenGenerator 38 | clock clock.Clock 39 | } 40 | 41 | func newResourceOwner(iter rangedb.RecordIterator, tokenGenerator TokenGenerator, clock clock.Clock) *resourceOwner { 42 | aggregate := &resourceOwner{ 43 | tokenGenerator: tokenGenerator, 44 | clock: clock, 45 | } 46 | 47 | for iter.Next() { 48 | if event, ok := iter.Record().Data.(rangedb.Event); ok { 49 | aggregate.apply(event) 50 | } 51 | } 52 | 53 | return aggregate 54 | } 55 | 56 | func (a *resourceOwner) apply(event rangedb.Event) { 57 | switch e := event.(type) { 58 | 59 | case *UserWasOnBoarded: 60 | a.IsOnBoarded = true 61 | a.Username = e.Username 62 | a.PasswordHash = e.PasswordHash 63 | 64 | case *UserWasAuthorizedToOnBoardClientApplications: 65 | a.IsAuthorizedToOnboardClientApplications = true 66 | 67 | case *UserWasGrantedAdministratorRole: 68 | a.IsAdministrator = true 69 | 70 | } 71 | } 72 | 73 | func (a *resourceOwner) GetPendingEvents() []rangedb.Event { 74 | return a.PendingEvents 75 | } 76 | 77 | func (a *resourceOwner) Handle(command Command) { 78 | switch c := command.(type) { 79 | 80 | case OnBoardUser: 81 | a.OnBoardUser(c) 82 | 83 | case GrantUserAdministratorRole: 84 | a.GrantUserAdministratorRole(c) 85 | 86 | case AuthorizeUserToOnBoardClientApplications: 87 | a.AuthorizeUserToOnBoardClientApplications(c) 88 | 89 | case RequestAccessTokenViaImplicitGrant: 90 | a.RequestAccessTokenViaImplicitGrant(c) 91 | 92 | case RequestAccessTokenViaROPCGrant: 93 | a.RequestAccessTokenViaROPCGrant(c) 94 | 95 | case RequestAuthorizationCodeViaAuthorizationCodeGrant: 96 | a.RequestAuthorizationCodeViaAuthorizationCodeGrant(c) 97 | 98 | } 99 | } 100 | 101 | func (a *resourceOwner) OnBoardUser(c OnBoardUser) { 102 | if a.IsOnBoarded { 103 | a.raise(OnBoardUserWasRejectedDueToExistingUser{ 104 | UserID: c.UserID, 105 | GrantingUserID: c.GrantingUserID, 106 | }) 107 | return 108 | } 109 | 110 | if securepass.IsInsecure(c.Password) { 111 | a.raise(OnBoardUserWasRejectedDueToInsecurePassword{ 112 | UserID: c.UserID, 113 | GrantingUserID: c.GrantingUserID, 114 | }) 115 | return 116 | } 117 | 118 | a.raise(UserWasOnBoarded{ 119 | UserID: c.UserID, 120 | Username: c.Username, 121 | PasswordHash: GeneratePasswordHash(c.Password), 122 | GrantingUserID: c.GrantingUserID, 123 | }) 124 | } 125 | 126 | func (a *resourceOwner) GrantUserAdministratorRole(c GrantUserAdministratorRole) { 127 | if !a.IsOnBoarded { 128 | a.raise(GrantUserAdministratorRoleWasRejectedDueToMissingTargetUser{ 129 | UserID: c.UserID, 130 | GrantingUserID: c.GrantingUserID, 131 | }) 132 | return 133 | } 134 | 135 | a.raise(UserWasGrantedAdministratorRole{ 136 | UserID: c.UserID, 137 | GrantingUserID: c.GrantingUserID, 138 | }) 139 | } 140 | 141 | func (a *resourceOwner) AuthorizeUserToOnBoardClientApplications(c AuthorizeUserToOnBoardClientApplications) { 142 | if !a.IsOnBoarded { 143 | a.raise(AuthorizeUserToOnBoardClientApplicationsWasRejectedDueToMissingTargetUser{ 144 | UserID: c.UserID, 145 | AuthorizingUserID: c.AuthorizingUserID, 146 | }) 147 | return 148 | } 149 | 150 | a.raise(UserWasAuthorizedToOnBoardClientApplications{ 151 | UserID: c.UserID, 152 | AuthorizingUserID: c.AuthorizingUserID, 153 | }) 154 | } 155 | 156 | func (a *resourceOwner) RequestAccessTokenViaImplicitGrant(c RequestAccessTokenViaImplicitGrant) { 157 | if !a.IsOnBoarded { 158 | a.raise(RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidUser{ 159 | UserID: c.UserID, 160 | ClientID: c.ClientID, 161 | }) 162 | return 163 | } 164 | 165 | if !a.isPasswordValid(c.Password) { 166 | a.raise(RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidUserPassword{ 167 | UserID: c.UserID, 168 | ClientID: c.ClientID, 169 | }) 170 | return 171 | } 172 | 173 | a.raise(AccessTokenWasIssuedToUserViaImplicitGrant{ 174 | UserID: c.UserID, 175 | ClientID: c.ClientID, 176 | }) 177 | } 178 | 179 | func (a *resourceOwner) RequestAccessTokenViaROPCGrant(c RequestAccessTokenViaROPCGrant) { 180 | if !a.IsOnBoarded { 181 | a.raise(RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidUser{ 182 | UserID: c.UserID, 183 | ClientID: c.ClientID, 184 | }) 185 | return 186 | } 187 | 188 | if !a.isPasswordValid(c.Password) { 189 | a.raise(RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidUserPassword{ 190 | UserID: c.UserID, 191 | ClientID: c.ClientID, 192 | }) 193 | return 194 | } 195 | 196 | token := a.tokenGenerator.New() 197 | expiresAt := a.clock.Now().Add(ropcGrantLifetime).Unix() 198 | 199 | a.raise( 200 | AccessTokenWasIssuedToUserViaROPCGrant{ 201 | UserID: c.UserID, 202 | ClientID: c.ClientID, 203 | ExpiresAt: expiresAt, 204 | Scope: c.Scope, 205 | }, 206 | RefreshTokenWasIssuedToUserViaROPCGrant{ 207 | UserID: c.UserID, 208 | ClientID: c.ClientID, 209 | RefreshToken: token, 210 | Scope: c.Scope, 211 | }, 212 | ) 213 | } 214 | 215 | func (a *resourceOwner) RequestAuthorizationCodeViaAuthorizationCodeGrant(c RequestAuthorizationCodeViaAuthorizationCodeGrant) { 216 | if !a.IsOnBoarded { 217 | a.raise(RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidUser{ 218 | UserID: c.UserID, 219 | ClientID: c.ClientID, 220 | }) 221 | return 222 | } 223 | 224 | if !a.isPasswordValid(c.Password) { 225 | a.raise(RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidUserPassword{ 226 | UserID: c.UserID, 227 | ClientID: c.ClientID, 228 | }) 229 | return 230 | } 231 | 232 | authorizationCode := a.tokenGenerator.New() 233 | 234 | expiresAt := a.clock.Now().Add(authorizationCodeLifetime).Unix() 235 | 236 | a.raise(AuthorizationCodeWasIssuedToUserViaAuthorizationCodeGrant{ 237 | UserID: c.UserID, 238 | ClientID: c.ClientID, 239 | AuthorizationCode: authorizationCode, 240 | ExpiresAt: expiresAt, 241 | Scope: c.Scope, 242 | }) 243 | } 244 | 245 | func (a *resourceOwner) isPasswordValid(password string) bool { 246 | return VerifyPassword(a.PasswordHash, password) 247 | } 248 | 249 | func (a *resourceOwner) raise(events ...rangedb.Event) { 250 | for _, event := range events { 251 | a.apply(event) 252 | } 253 | 254 | a.PendingEvents = append(a.PendingEvents, events...) 255 | } 256 | -------------------------------------------------------------------------------- /resource_owner_command_authorization.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/clock" 8 | ) 9 | 10 | type resourceOwnerCommandAuthorization struct { 11 | store rangedb.Store 12 | clock clock.Clock 13 | tokenGenerator TokenGenerator 14 | pendingEvents []rangedb.Event 15 | } 16 | 17 | func newResourceOwnerCommandAuthorization( 18 | store rangedb.Store, 19 | tokenGenerator TokenGenerator, 20 | clock clock.Clock, 21 | ) *resourceOwnerCommandAuthorization { 22 | return &resourceOwnerCommandAuthorization{ 23 | store: store, 24 | tokenGenerator: tokenGenerator, 25 | clock: clock, 26 | } 27 | } 28 | 29 | func (a *resourceOwnerCommandAuthorization) GetPendingEvents() []rangedb.Event { 30 | return a.pendingEvents 31 | } 32 | 33 | func (a *resourceOwnerCommandAuthorization) CommandTypes() []string { 34 | return []string{ 35 | GrantUserAdministratorRole{}.CommandType(), 36 | AuthorizeUserToOnBoardClientApplications{}.CommandType(), 37 | OnBoardClientApplication{}.CommandType(), 38 | OnBoardUser{}.CommandType(), 39 | } 40 | } 41 | 42 | func (a *resourceOwnerCommandAuthorization) Handle(command Command) bool { 43 | switch c := command.(type) { 44 | 45 | case GrantUserAdministratorRole: 46 | return a.GrantUserAdministratorRole(c) 47 | 48 | case AuthorizeUserToOnBoardClientApplications: 49 | return a.AuthorizeUserToOnBoardClientApplications(c) 50 | 51 | case OnBoardClientApplication: 52 | return a.OnBoardClientApplication(c) 53 | 54 | case OnBoardUser: 55 | return a.OnBoardUser(c) 56 | 57 | } 58 | 59 | return true 60 | } 61 | 62 | func (a *resourceOwnerCommandAuthorization) GrantUserAdministratorRole(c GrantUserAdministratorRole) bool { 63 | grantingUser := a.loadResourceOwnerAggregate(c.GrantingUserID) 64 | 65 | if !grantingUser.IsOnBoarded { 66 | a.raise(GrantUserAdministratorRoleWasRejectedDueToMissingGrantingUser{ 67 | UserID: c.UserID, 68 | GrantingUserID: c.GrantingUserID, 69 | }) 70 | return false 71 | } 72 | 73 | if !grantingUser.IsAdministrator { 74 | a.raise(GrantUserAdministratorRoleWasRejectedDueToNonAdministrator{ 75 | UserID: c.UserID, 76 | GrantingUserID: c.GrantingUserID, 77 | }) 78 | return false 79 | } 80 | 81 | return true 82 | } 83 | 84 | func (a *resourceOwnerCommandAuthorization) AuthorizeUserToOnBoardClientApplications(c AuthorizeUserToOnBoardClientApplications) bool { 85 | authorizingUser := a.loadResourceOwnerAggregate(c.AuthorizingUserID) 86 | 87 | if !authorizingUser.IsOnBoarded { 88 | a.raise(AuthorizeUserToOnBoardClientApplicationsWasRejectedDueToMissingAuthorizingUser{ 89 | UserID: c.UserID, 90 | AuthorizingUserID: c.AuthorizingUserID, 91 | }) 92 | return false 93 | } 94 | 95 | if !authorizingUser.IsAdministrator { 96 | a.raise(AuthorizeUserToOnBoardClientApplicationsWasRejectedDueToNonAdministrator{ 97 | UserID: c.UserID, 98 | AuthorizingUserID: c.AuthorizingUserID, 99 | }) 100 | return false 101 | } 102 | 103 | return true 104 | } 105 | 106 | func (a *resourceOwnerCommandAuthorization) OnBoardClientApplication(c OnBoardClientApplication) bool { 107 | resourceOwner := a.loadResourceOwnerAggregate(c.UserID) 108 | 109 | if !resourceOwner.IsOnBoarded { 110 | a.raise(OnBoardClientApplicationWasRejectedDueToUnAuthorizeUser{ 111 | ClientID: c.ClientID, 112 | UserID: c.UserID, 113 | }) 114 | return false 115 | } 116 | 117 | if !resourceOwner.IsAuthorizedToOnboardClientApplications { 118 | a.raise(OnBoardClientApplicationWasRejectedDueToUnAuthorizeUser{ 119 | ClientID: c.ClientID, 120 | UserID: c.UserID, 121 | }) 122 | return false 123 | } 124 | 125 | return true 126 | } 127 | 128 | func (a *resourceOwnerCommandAuthorization) OnBoardUser(c OnBoardUser) bool { 129 | resourceOwner := a.loadResourceOwnerAggregate(c.GrantingUserID) 130 | 131 | if !resourceOwner.IsOnBoarded { 132 | a.raise(OnBoardUserWasRejectedDueToNonAdministrator{ 133 | UserID: c.UserID, 134 | GrantingUserID: c.GrantingUserID, 135 | }) 136 | return false 137 | } 138 | 139 | if !resourceOwner.IsAdministrator { 140 | a.raise(OnBoardUserWasRejectedDueToNonAdministrator{ 141 | UserID: c.UserID, 142 | GrantingUserID: c.GrantingUserID, 143 | }) 144 | return false 145 | } 146 | 147 | return true 148 | } 149 | 150 | func (a *resourceOwnerCommandAuthorization) raise(events ...rangedb.Event) { 151 | a.pendingEvents = append(a.pendingEvents, events...) 152 | } 153 | 154 | func (a *resourceOwnerCommandAuthorization) loadResourceOwnerAggregate(userID string) *resourceOwner { 155 | return newResourceOwner( 156 | a.store.EventsByStream(context.Background(), 0, resourceOwnerStream(userID)), 157 | a.tokenGenerator, 158 | a.clock, 159 | ) 160 | } 161 | -------------------------------------------------------------------------------- /resource_owner_commands.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/commandgenerator -id UserID -aggregateType resource-owner 4 | 5 | type OnBoardUser struct { 6 | UserID string `json:"userID"` 7 | Username string `json:"username"` 8 | Password string `json:"password"` 9 | GrantingUserID string `json:"grantingUserID"` 10 | } 11 | type GrantUserAdministratorRole struct { 12 | UserID string `json:"userID"` 13 | GrantingUserID string `json:"grantingUserID"` 14 | } 15 | type AuthorizeUserToOnBoardClientApplications struct { 16 | UserID string `json:"userID"` 17 | AuthorizingUserID string `json:"authorizingUserID"` 18 | } 19 | type RequestAccessTokenViaImplicitGrant struct { 20 | UserID string `json:"userID"` 21 | ClientID string `json:"clientID"` 22 | RedirectURI string `json:"redirectURI"` 23 | Username string `json:"username"` 24 | Password string `json:"password"` 25 | } 26 | type RequestAccessTokenViaROPCGrant struct { 27 | UserID string `json:"userID"` 28 | ClientID string `json:"clientID"` 29 | ClientSecret string `json:"clientSecret"` 30 | Username string `json:"username"` 31 | Password string `json:"password"` 32 | Scope string `json:"scope"` 33 | } 34 | type RequestAuthorizationCodeViaAuthorizationCodeGrant struct { 35 | UserID string `json:"userID"` 36 | ClientID string `json:"clientID"` 37 | RedirectURI string `json:"redirectURI"` 38 | Username string `json:"username"` 39 | Password string `json:"password"` 40 | Scope string `json:"scope"` 41 | } 42 | -------------------------------------------------------------------------------- /resource_owner_events.go: -------------------------------------------------------------------------------- 1 | package goauth2 2 | 3 | //go:generate go run github.com/inklabs/rangedb/gen/eventgenerator -id UserID -aggregateType resource-owner 4 | 5 | // OnBoardUser Events 6 | 7 | type UserWasOnBoarded struct { 8 | UserID string `json:"userID"` 9 | Username string `json:"username"` 10 | PasswordHash string `json:"passwordHash"` 11 | GrantingUserID string `json:"grantingUserID"` 12 | } 13 | type OnBoardUserWasRejectedDueToNonAdministrator struct { 14 | UserID string `json:"userID"` 15 | GrantingUserID string `json:"grantingUserID"` 16 | } 17 | type OnBoardUserWasRejectedDueToExistingUser struct { 18 | UserID string `json:"userID"` 19 | GrantingUserID string `json:"grantingUserID"` 20 | } 21 | type OnBoardUserWasRejectedDueToInsecurePassword struct { 22 | UserID string `json:"userID"` 23 | GrantingUserID string `json:"grantingUserID"` 24 | } 25 | 26 | // GrantUserAdministratorRole Events 27 | 28 | type UserWasGrantedAdministratorRole struct { 29 | UserID string `json:"userID"` 30 | GrantingUserID string `json:"grantingUserID"` 31 | } 32 | type GrantUserAdministratorRoleWasRejectedDueToMissingGrantingUser struct { 33 | UserID string `json:"userID"` 34 | GrantingUserID string `json:"grantingUserID"` 35 | } 36 | type GrantUserAdministratorRoleWasRejectedDueToMissingTargetUser struct { 37 | UserID string `json:"userID"` 38 | GrantingUserID string `json:"grantingUserID"` 39 | } 40 | type GrantUserAdministratorRoleWasRejectedDueToNonAdministrator struct { 41 | UserID string `json:"userID"` 42 | GrantingUserID string `json:"grantingUserID"` 43 | } 44 | 45 | // AuthorizeUserToOnBoardClientApplications Events 46 | 47 | type UserWasAuthorizedToOnBoardClientApplications struct { 48 | UserID string `json:"userID"` 49 | AuthorizingUserID string `json:"authorizingUserID"` 50 | } 51 | type AuthorizeUserToOnBoardClientApplicationsWasRejectedDueToMissingAuthorizingUser struct { 52 | UserID string `json:"userID"` 53 | AuthorizingUserID string `json:"authorizingUserID"` 54 | } 55 | type AuthorizeUserToOnBoardClientApplicationsWasRejectedDueToMissingTargetUser struct { 56 | UserID string `json:"userID"` 57 | AuthorizingUserID string `json:"authorizingUserID"` 58 | } 59 | type AuthorizeUserToOnBoardClientApplicationsWasRejectedDueToNonAdministrator struct { 60 | UserID string `json:"userID"` 61 | AuthorizingUserID string `json:"authorizingUserID"` 62 | } 63 | 64 | // RequestAccessTokenViaImplicitGrant Events 65 | 66 | type AccessTokenWasIssuedToUserViaImplicitGrant struct { 67 | UserID string `json:"userID"` 68 | ClientID string `json:"clientID"` 69 | } 70 | type RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidClientApplicationID struct { 71 | UserID string `json:"userID"` 72 | ClientID string `json:"clientID"` 73 | } 74 | type RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidClientApplicationRedirectURI struct { 75 | UserID string `json:"userID"` 76 | ClientID string `json:"clientID"` 77 | RedirectURI string `json:"redirectURI"` 78 | } 79 | type RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidUser struct { 80 | UserID string `json:"userID"` 81 | ClientID string `json:"clientID"` 82 | } 83 | type RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidUserPassword struct { 84 | UserID string `json:"userID"` 85 | ClientID string `json:"clientID"` 86 | } 87 | 88 | // RequestAccessTokenViaROPCGrant Events 89 | 90 | type AccessTokenWasIssuedToUserViaROPCGrant struct { 91 | UserID string `json:"userID"` 92 | ClientID string `json:"clientID"` 93 | ExpiresAt int64 `json:"expiresAt"` 94 | Scope string `json:"scope"` 95 | } 96 | type RefreshTokenWasIssuedToUserViaROPCGrant struct { 97 | UserID string `json:"userID"` 98 | ClientID string `json:"clientID"` 99 | RefreshToken string `json:"refreshToken"` 100 | Scope string `json:"scope"` 101 | } 102 | type RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidUser struct { 103 | UserID string `json:"userID"` 104 | ClientID string `json:"clientID"` 105 | } 106 | type RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidUserPassword struct { 107 | UserID string `json:"userID"` 108 | ClientID string `json:"clientID"` 109 | } 110 | type RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidClientApplicationCredentials struct { 111 | UserID string `json:"userID"` 112 | ClientID string `json:"clientID"` 113 | } 114 | 115 | // RequestAuthorizationCodeViaAuthorizationCodeGrant Events 116 | 117 | type AuthorizationCodeWasIssuedToUserViaAuthorizationCodeGrant struct { 118 | UserID string `json:"userID"` 119 | ClientID string `json:"clientID"` 120 | AuthorizationCode string `json:"authorizationCode"` 121 | ExpiresAt int64 `json:"expiresAt"` 122 | Scope string `json:"scope"` 123 | } 124 | type RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationID struct { 125 | UserID string `json:"userID"` 126 | ClientID string `json:"clientID"` 127 | } 128 | type RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationRedirectURI struct { 129 | UserID string `json:"userID"` 130 | ClientID string `json:"clientID"` 131 | RedirectURI string `json:"redirectURI"` 132 | } 133 | type RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidUser struct { 134 | UserID string `json:"userID"` 135 | ClientID string `json:"clientID"` 136 | } 137 | type RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidUserPassword struct { 138 | UserID string `json:"userID"` 139 | ClientID string `json:"clientID"` 140 | } 141 | -------------------------------------------------------------------------------- /web/admin_web_app.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/gorilla/csrf" 9 | "github.com/gorilla/mux" 10 | 11 | "github.com/inklabs/goauth2" 12 | ) 13 | 14 | func (a *webApp) addAdminRoutes(r *mux.Router) { 15 | csrfMiddleware := csrf.Protect( 16 | a.csrfAuthKey, 17 | csrf.Secure(false), 18 | csrf.SameSite(csrf.SameSiteStrictMode), 19 | ) 20 | 21 | r.HandleFunc("/admin-login", a.showAdminLogin).Methods(http.MethodGet) 22 | r.HandleFunc("/admin-login", a.submitAdminLogin).Methods(http.MethodPost) 23 | 24 | admin := r.PathPrefix("/admin").Subrouter() 25 | admin.HandleFunc("/add-user", a.showAddUser).Methods(http.MethodGet) 26 | admin.HandleFunc("/add-user", a.submitAddUser).Methods(http.MethodPost) 27 | admin.HandleFunc("/list-users", a.listUsers) 28 | admin.HandleFunc("/list-client-applications", a.listClientApplications) 29 | admin.Use( 30 | a.adminAuthorization, 31 | csrfMiddleware, 32 | ) 33 | } 34 | 35 | type clientApplication struct { 36 | ClientID string 37 | ClientSecret string 38 | CreateTimestamp uint64 39 | } 40 | 41 | type listClientApplicationsTemplateVars struct { 42 | flashMessageVars 43 | ClientApplications []clientApplication 44 | } 45 | 46 | func (a *webApp) listClientApplications(w http.ResponseWriter, _ *http.Request) { 47 | 48 | var clientApplications []clientApplication 49 | 50 | for _, ca := range a.projections.clientApplications.GetAll() { 51 | clientApplications = append(clientApplications, clientApplication{ 52 | ClientID: ca.ClientID, 53 | ClientSecret: ca.ClientSecret, 54 | CreateTimestamp: ca.CreateTimestamp, 55 | }) 56 | } 57 | 58 | a.renderTemplate(w, "admin/list-client-applications.gohtml", listClientApplicationsTemplateVars{ 59 | ClientApplications: clientApplications, 60 | }) 61 | } 62 | 63 | type flashMessageVars struct { 64 | Errors []string 65 | Messages []string 66 | } 67 | 68 | type resourceOwnerUser struct { 69 | UserID string 70 | Username string 71 | GrantingUserID string 72 | CreateTimestamp uint64 73 | IsAdmin bool 74 | CanOnboardAdminApplications bool 75 | } 76 | 77 | type listUsersTemplateVars struct { 78 | flashMessageVars 79 | Users []resourceOwnerUser 80 | } 81 | 82 | func (a *webApp) listUsers(w http.ResponseWriter, r *http.Request) { 83 | 84 | var users []resourceOwnerUser 85 | 86 | for _, user := range a.projections.users.GetAll() { 87 | users = append(users, resourceOwnerUser{ 88 | UserID: user.UserID, 89 | Username: user.Username, 90 | GrantingUserID: user.GrantingUserID, 91 | CreateTimestamp: user.CreateTimestamp, 92 | IsAdmin: user.IsAdmin, 93 | CanOnboardAdminApplications: user.CanOnboardAdminApplications, 94 | }) 95 | } 96 | 97 | a.renderTemplate(w, "admin/list-users.gohtml", listUsersTemplateVars{ 98 | Users: users, 99 | flashMessageVars: a.getFlashMessageVars(w, r), 100 | }) 101 | } 102 | 103 | type adminLoginTemplateVars struct { 104 | flashMessageVars 105 | Username string 106 | CSRFField template.HTML 107 | } 108 | 109 | func (a *webApp) showAdminLogin(w http.ResponseWriter, r *http.Request) { 110 | a.renderTemplate(w, "admin/login.gohtml", adminLoginTemplateVars{ 111 | Username: "", // TODO: Add when form post fails on redirect 112 | CSRFField: csrf.TemplateField(r), 113 | flashMessageVars: a.getFlashMessageVars(w, r), 114 | }) 115 | } 116 | 117 | func (a *webApp) submitAdminLogin(w http.ResponseWriter, r *http.Request) { 118 | err := r.ParseForm() 119 | if err != nil { 120 | writeInvalidRequestResponse(w) 121 | return 122 | } 123 | 124 | username := r.Form.Get("username") 125 | password := r.Form.Get("password") 126 | redirectURI := r.Form.Get("redirect") 127 | 128 | userID, err := a.projections.emailToUserID.GetUserID(username) 129 | if err != nil { 130 | writeInvalidRequestResponse(w) 131 | return 132 | } 133 | 134 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.RequestAccessTokenViaROPCGrant{ 135 | UserID: userID, 136 | ClientID: ClientIDTODO, 137 | ClientSecret: ClientSecretTODO, 138 | Username: username, 139 | Password: password, 140 | Scope: "read_write", 141 | })) 142 | var accessTokenEvent goauth2.AccessTokenWasIssuedToUserViaROPCGrant 143 | if !events.Get(&accessTokenEvent) { 144 | writeInvalidRequestResponse(w) 145 | return 146 | } 147 | 148 | user, err := a.projections.users.Get(userID) 149 | if err != nil { 150 | writeInvalidRequestResponse(w) 151 | return 152 | } 153 | 154 | authenticatedUser := AuthenticatedUser{ 155 | UserID: userID, 156 | Username: user.Username, 157 | IsAdmin: user.IsAdmin, 158 | } 159 | err = a.setAuthenticatedUser(w, r, authenticatedUser) 160 | if err != nil { 161 | writeInternalServerErrorResponse(w) 162 | return 163 | } 164 | 165 | if redirectURI == "" { 166 | redirectURI = "/admin/list-users" 167 | } 168 | 169 | uri, err := url.Parse(redirectURI) 170 | if err != nil { 171 | writeInvalidRequestResponse(w) 172 | return 173 | } 174 | 175 | http.Redirect(w, r, uri.String(), http.StatusSeeOther) 176 | } 177 | 178 | type addUserTemplateVars struct { 179 | flashMessageVars 180 | Username string 181 | CSRFField template.HTML 182 | } 183 | 184 | func (a *webApp) showAddUser(w http.ResponseWriter, r *http.Request) { 185 | a.renderTemplate(w, "admin/add-user.gohtml", addUserTemplateVars{ 186 | Username: "", // TODO: Add when form post fails on redirect 187 | CSRFField: csrf.TemplateField(r), 188 | flashMessageVars: a.getFlashMessageVars(w, r), 189 | }) 190 | } 191 | 192 | func (a *webApp) submitAddUser(w http.ResponseWriter, r *http.Request) { 193 | err := r.ParseForm() 194 | if err != nil { 195 | writeInvalidRequestResponse(w) 196 | return 197 | } 198 | 199 | username := r.Form.Get("username") 200 | password := r.Form.Get("password") 201 | // confirmPassword := r.Form.Get("confirm_password") 202 | 203 | if username == "" || password == "" { 204 | redirectURI := url.URL{ 205 | Path: "/admin/add-user", 206 | } 207 | a.FlashError(w, r, "username or password are required") 208 | http.Redirect(w, r, redirectURI.String(), http.StatusFound) 209 | return 210 | } 211 | 212 | authenticatedUser, err := a.getAuthenticatedUser(r) 213 | if err != nil { 214 | writeInternalServerErrorResponse(w) 215 | return 216 | } 217 | 218 | userID := a.uuidGenerator.New() 219 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.OnBoardUser{ 220 | UserID: userID, 221 | Username: username, 222 | Password: password, 223 | GrantingUserID: authenticatedUser.UserID, 224 | })) 225 | var userWasOnBoarded goauth2.UserWasOnBoarded 226 | if !events.Get(&userWasOnBoarded) { 227 | redirectURI := url.URL{ 228 | Path: "/admin/add-user", 229 | } 230 | http.Redirect(w, r, redirectURI.String(), http.StatusFound) 231 | return 232 | } 233 | 234 | a.FlashMessage(w, r, "User (%s) was added", username) 235 | 236 | uri := url.URL{ 237 | Path: "/admin/list-users", 238 | } 239 | http.Redirect(w, r, uri.String(), http.StatusFound) 240 | } 241 | 242 | type AuthenticatedUser struct { 243 | UserID string 244 | Username string 245 | IsAdmin bool 246 | } 247 | 248 | func (a *webApp) getAuthenticatedUser(r *http.Request) (AuthenticatedUser, error) { 249 | session, err := a.sessionStore.Get(r, sessionName) 250 | if err != nil { 251 | return AuthenticatedUser{}, err 252 | } 253 | 254 | if user, ok := session.Values["user"].(AuthenticatedUser); ok { 255 | return user, nil 256 | } 257 | 258 | return AuthenticatedUser{}, err 259 | } 260 | 261 | func (a *webApp) setAuthenticatedUser(w http.ResponseWriter, r *http.Request, user AuthenticatedUser) error { 262 | session, err := a.sessionStore.Get(r, sessionName) 263 | if err != nil { 264 | return err 265 | } 266 | 267 | session.Values["user"] = user 268 | 269 | return session.Save(r, w) 270 | } 271 | 272 | func (a *webApp) adminAuthorization(h http.Handler) http.Handler { 273 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 274 | authenticatedUser, err := a.getAuthenticatedUser(r) 275 | if err == nil && authenticatedUser.IsAdmin { 276 | h.ServeHTTP(w, r) 277 | return 278 | } 279 | 280 | params := &url.Values{} 281 | params.Set("redirect", r.RequestURI) 282 | loginUri := url.URL{ 283 | Path: "/admin-login", 284 | RawQuery: params.Encode(), 285 | } 286 | http.Redirect(w, r, loginUri.String(), http.StatusSeeOther) 287 | }) 288 | } 289 | -------------------------------------------------------------------------------- /web/functions.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html/template" 5 | "time" 6 | 7 | "github.com/inklabs/goauth2" 8 | ) 9 | 10 | var funcMap = template.FuncMap{ 11 | "formatDate": formatDate, 12 | "goAuth2Version": func() string { 13 | return goauth2.Version 14 | }, 15 | } 16 | 17 | func formatDate(timestamp uint64, layout string) string { 18 | return time.Unix(int64(timestamp), 0).UTC().Format(layout) 19 | } 20 | -------------------------------------------------------------------------------- /web/saved_events.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | // SavedEvents contains events that have been persisted to the event store. 10 | type SavedEvents []rangedb.Event 11 | 12 | // Contains returns true if all events are found. 13 | func (l *SavedEvents) Contains(events ...rangedb.Event) bool { 14 | var totalFound int 15 | for _, event := range events { 16 | for _, savedEvent := range *l { 17 | if event.EventType() == savedEvent.EventType() { 18 | totalFound++ 19 | break 20 | } 21 | } 22 | } 23 | return len(events) == totalFound 24 | } 25 | 26 | // ContainsAny returns true if any events are found. 27 | func (l *SavedEvents) ContainsAny(events ...rangedb.Event) bool { 28 | for _, event := range events { 29 | for _, savedEvent := range *l { 30 | if event.EventType() == savedEvent.EventType() { 31 | return true 32 | } 33 | } 34 | } 35 | 36 | return false 37 | } 38 | 39 | // Get returns true if the event was found and stores the result 40 | // in the value pointed to by event. If it is not found, Get 41 | // returns false. 42 | func (l *SavedEvents) Get(event rangedb.Event) bool { 43 | for _, savedEvent := range *l { 44 | if event.EventType() == savedEvent.EventType() { 45 | eventVal := reflect.ValueOf(event) 46 | savedEventVal := reflect.ValueOf(savedEvent) 47 | 48 | if savedEventVal.Kind() == reflect.Ptr { 49 | savedEventVal = savedEventVal.Elem() 50 | } 51 | 52 | if savedEventVal.Type().AssignableTo(eventVal.Type().Elem()) { 53 | eventVal.Elem().Set(savedEventVal) 54 | return true 55 | } 56 | } 57 | } 58 | 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /web/static/css/foundation-icons.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Foundation Icons v 3.0 3 | * Made by ZURB 2013 http://zurb.com/playground/foundation-icon-fonts-3 4 | * MIT License 5 | */ 6 | 7 | @font-face { 8 | font-family: "foundation-icons"; 9 | src: url("foundation-icons.woff") format("woff"); 10 | font-weight: normal; 11 | font-style: normal; 12 | } 13 | 14 | .fi-address-book:before, 15 | .fi-alert:before, 16 | .fi-align-center:before, 17 | .fi-align-justify:before, 18 | .fi-align-left:before, 19 | .fi-align-right:before, 20 | .fi-anchor:before, 21 | .fi-annotate:before, 22 | .fi-archive:before, 23 | .fi-arrow-down:before, 24 | .fi-arrow-left:before, 25 | .fi-arrow-right:before, 26 | .fi-arrow-up:before, 27 | .fi-arrows-compress:before, 28 | .fi-arrows-expand:before, 29 | .fi-arrows-in:before, 30 | .fi-arrows-out:before, 31 | .fi-asl:before, 32 | .fi-asterisk:before, 33 | .fi-at-sign:before, 34 | .fi-background-color:before, 35 | .fi-battery-empty:before, 36 | .fi-battery-full:before, 37 | .fi-battery-half:before, 38 | .fi-bitcoin-circle:before, 39 | .fi-bitcoin:before, 40 | .fi-blind:before, 41 | .fi-bluetooth:before, 42 | .fi-bold:before, 43 | .fi-book-bookmark:before, 44 | .fi-book:before, 45 | .fi-bookmark:before, 46 | .fi-braille:before, 47 | .fi-burst-new:before, 48 | .fi-burst-sale:before, 49 | .fi-burst:before, 50 | .fi-calendar:before, 51 | .fi-camera:before, 52 | .fi-check:before, 53 | .fi-checkbox:before, 54 | .fi-clipboard-notes:before, 55 | .fi-clipboard-pencil:before, 56 | .fi-clipboard:before, 57 | .fi-clock:before, 58 | .fi-closed-caption:before, 59 | .fi-cloud:before, 60 | .fi-comment-minus:before, 61 | .fi-comment-quotes:before, 62 | .fi-comment-video:before, 63 | .fi-comment:before, 64 | .fi-comments:before, 65 | .fi-compass:before, 66 | .fi-contrast:before, 67 | .fi-credit-card:before, 68 | .fi-crop:before, 69 | .fi-crown:before, 70 | .fi-css3:before, 71 | .fi-database:before, 72 | .fi-die-five:before, 73 | .fi-die-four:before, 74 | .fi-die-one:before, 75 | .fi-die-six:before, 76 | .fi-die-three:before, 77 | .fi-die-two:before, 78 | .fi-dislike:before, 79 | .fi-dollar-bill:before, 80 | .fi-dollar:before, 81 | .fi-download:before, 82 | .fi-eject:before, 83 | .fi-elevator:before, 84 | .fi-euro:before, 85 | .fi-eye:before, 86 | .fi-fast-forward:before, 87 | .fi-female-symbol:before, 88 | .fi-female:before, 89 | .fi-filter:before, 90 | .fi-first-aid:before, 91 | .fi-flag:before, 92 | .fi-folder-add:before, 93 | .fi-folder-lock:before, 94 | .fi-folder:before, 95 | .fi-foot:before, 96 | .fi-foundation:before, 97 | .fi-graph-bar:before, 98 | .fi-graph-horizontal:before, 99 | .fi-graph-pie:before, 100 | .fi-graph-trend:before, 101 | .fi-guide-dog:before, 102 | .fi-hearing-aid:before, 103 | .fi-heart:before, 104 | .fi-home:before, 105 | .fi-html5:before, 106 | .fi-indent-less:before, 107 | .fi-indent-more:before, 108 | .fi-info:before, 109 | .fi-italic:before, 110 | .fi-key:before, 111 | .fi-laptop:before, 112 | .fi-layout:before, 113 | .fi-lightbulb:before, 114 | .fi-like:before, 115 | .fi-link:before, 116 | .fi-list-bullet:before, 117 | .fi-list-number:before, 118 | .fi-list-thumbnails:before, 119 | .fi-list:before, 120 | .fi-lock:before, 121 | .fi-loop:before, 122 | .fi-magnifying-glass:before, 123 | .fi-mail:before, 124 | .fi-male-female:before, 125 | .fi-male-symbol:before, 126 | .fi-male:before, 127 | .fi-map:before, 128 | .fi-marker:before, 129 | .fi-megaphone:before, 130 | .fi-microphone:before, 131 | .fi-minus-circle:before, 132 | .fi-minus:before, 133 | .fi-mobile-signal:before, 134 | .fi-mobile:before, 135 | .fi-monitor:before, 136 | .fi-mountains:before, 137 | .fi-music:before, 138 | .fi-next:before, 139 | .fi-no-dogs:before, 140 | .fi-no-smoking:before, 141 | .fi-page-add:before, 142 | .fi-page-copy:before, 143 | .fi-page-csv:before, 144 | .fi-page-delete:before, 145 | .fi-page-doc:before, 146 | .fi-page-edit:before, 147 | .fi-page-export-csv:before, 148 | .fi-page-export-doc:before, 149 | .fi-page-export-pdf:before, 150 | .fi-page-export:before, 151 | .fi-page-filled:before, 152 | .fi-page-multiple:before, 153 | .fi-page-pdf:before, 154 | .fi-page-remove:before, 155 | .fi-page-search:before, 156 | .fi-page:before, 157 | .fi-paint-bucket:before, 158 | .fi-paperclip:before, 159 | .fi-pause:before, 160 | .fi-paw:before, 161 | .fi-paypal:before, 162 | .fi-pencil:before, 163 | .fi-photo:before, 164 | .fi-play-circle:before, 165 | .fi-play-video:before, 166 | .fi-play:before, 167 | .fi-plus:before, 168 | .fi-pound:before, 169 | .fi-power:before, 170 | .fi-previous:before, 171 | .fi-price-tag:before, 172 | .fi-pricetag-multiple:before, 173 | .fi-print:before, 174 | .fi-prohibited:before, 175 | .fi-projection-screen:before, 176 | .fi-puzzle:before, 177 | .fi-quote:before, 178 | .fi-record:before, 179 | .fi-refresh:before, 180 | .fi-results-demographics:before, 181 | .fi-results:before, 182 | .fi-rewind-ten:before, 183 | .fi-rewind:before, 184 | .fi-rss:before, 185 | .fi-safety-cone:before, 186 | .fi-save:before, 187 | .fi-share:before, 188 | .fi-sheriff-badge:before, 189 | .fi-shield:before, 190 | .fi-shopping-bag:before, 191 | .fi-shopping-cart:before, 192 | .fi-shuffle:before, 193 | .fi-skull:before, 194 | .fi-social-500px:before, 195 | .fi-social-adobe:before, 196 | .fi-social-amazon:before, 197 | .fi-social-android:before, 198 | .fi-social-apple:before, 199 | .fi-social-behance:before, 200 | .fi-social-bing:before, 201 | .fi-social-blogger:before, 202 | .fi-social-delicious:before, 203 | .fi-social-designer-news:before, 204 | .fi-social-deviant-art:before, 205 | .fi-social-digg:before, 206 | .fi-social-dribbble:before, 207 | .fi-social-drive:before, 208 | .fi-social-dropbox:before, 209 | .fi-social-evernote:before, 210 | .fi-social-facebook:before, 211 | .fi-social-flickr:before, 212 | .fi-social-forrst:before, 213 | .fi-social-foursquare:before, 214 | .fi-social-game-center:before, 215 | .fi-social-github:before, 216 | .fi-social-google-plus:before, 217 | .fi-social-hacker-news:before, 218 | .fi-social-hi5:before, 219 | .fi-social-instagram:before, 220 | .fi-social-joomla:before, 221 | .fi-social-lastfm:before, 222 | .fi-social-linkedin:before, 223 | .fi-social-medium:before, 224 | .fi-social-myspace:before, 225 | .fi-social-orkut:before, 226 | .fi-social-path:before, 227 | .fi-social-picasa:before, 228 | .fi-social-pinterest:before, 229 | .fi-social-rdio:before, 230 | .fi-social-reddit:before, 231 | .fi-social-skillshare:before, 232 | .fi-social-skype:before, 233 | .fi-social-smashing-mag:before, 234 | .fi-social-snapchat:before, 235 | .fi-social-spotify:before, 236 | .fi-social-squidoo:before, 237 | .fi-social-stack-overflow:before, 238 | .fi-social-steam:before, 239 | .fi-social-stumbleupon:before, 240 | .fi-social-treehouse:before, 241 | .fi-social-tumblr:before, 242 | .fi-social-twitter:before, 243 | .fi-social-vimeo:before, 244 | .fi-social-windows:before, 245 | .fi-social-xbox:before, 246 | .fi-social-yahoo:before, 247 | .fi-social-yelp:before, 248 | .fi-social-youtube:before, 249 | .fi-social-zerply:before, 250 | .fi-social-zurb:before, 251 | .fi-sound:before, 252 | .fi-star:before, 253 | .fi-stop:before, 254 | .fi-strikethrough:before, 255 | .fi-subscript:before, 256 | .fi-superscript:before, 257 | .fi-tablet-landscape:before, 258 | .fi-tablet-portrait:before, 259 | .fi-target-two:before, 260 | .fi-target:before, 261 | .fi-telephone-accessible:before, 262 | .fi-telephone:before, 263 | .fi-text-color:before, 264 | .fi-thumbnails:before, 265 | .fi-ticket:before, 266 | .fi-torso-business:before, 267 | .fi-torso-female:before, 268 | .fi-torso:before, 269 | .fi-torsos-all-female:before, 270 | .fi-torsos-all:before, 271 | .fi-torsos-female-male:before, 272 | .fi-torsos-male-female:before, 273 | .fi-torsos:before, 274 | .fi-trash:before, 275 | .fi-trees:before, 276 | .fi-trophy:before, 277 | .fi-underline:before, 278 | .fi-universal-access:before, 279 | .fi-unlink:before, 280 | .fi-unlock:before, 281 | .fi-upload-cloud:before, 282 | .fi-upload:before, 283 | .fi-usb:before, 284 | .fi-video:before, 285 | .fi-volume-none:before, 286 | .fi-volume-strike:before, 287 | .fi-volume:before, 288 | .fi-web:before, 289 | .fi-wheelchair:before, 290 | .fi-widget:before, 291 | .fi-wrench:before, 292 | .fi-x-circle:before, 293 | .fi-x:before, 294 | .fi-yen:before, 295 | .fi-zoom-in:before, 296 | .fi-zoom-out:before { 297 | font-family: "foundation-icons"; 298 | font-style: normal; 299 | font-weight: normal; 300 | font-variant: normal; 301 | text-transform: none; 302 | line-height: 1; 303 | -webkit-font-smoothing: antialiased; 304 | display: inline-block; 305 | text-decoration: inherit; 306 | } 307 | 308 | .fi-address-book:before { content: "\f100"; } 309 | .fi-alert:before { content: "\f101"; } 310 | .fi-align-center:before { content: "\f102"; } 311 | .fi-align-justify:before { content: "\f103"; } 312 | .fi-align-left:before { content: "\f104"; } 313 | .fi-align-right:before { content: "\f105"; } 314 | .fi-anchor:before { content: "\f106"; } 315 | .fi-annotate:before { content: "\f107"; } 316 | .fi-archive:before { content: "\f108"; } 317 | .fi-arrow-down:before { content: "\f109"; } 318 | .fi-arrow-left:before { content: "\f10a"; } 319 | .fi-arrow-right:before { content: "\f10b"; } 320 | .fi-arrow-up:before { content: "\f10c"; } 321 | .fi-arrows-compress:before { content: "\f10d"; } 322 | .fi-arrows-expand:before { content: "\f10e"; } 323 | .fi-arrows-in:before { content: "\f10f"; } 324 | .fi-arrows-out:before { content: "\f110"; } 325 | .fi-asl:before { content: "\f111"; } 326 | .fi-asterisk:before { content: "\f112"; } 327 | .fi-at-sign:before { content: "\f113"; } 328 | .fi-background-color:before { content: "\f114"; } 329 | .fi-battery-empty:before { content: "\f115"; } 330 | .fi-battery-full:before { content: "\f116"; } 331 | .fi-battery-half:before { content: "\f117"; } 332 | .fi-bitcoin-circle:before { content: "\f118"; } 333 | .fi-bitcoin:before { content: "\f119"; } 334 | .fi-blind:before { content: "\f11a"; } 335 | .fi-bluetooth:before { content: "\f11b"; } 336 | .fi-bold:before { content: "\f11c"; } 337 | .fi-book-bookmark:before { content: "\f11d"; } 338 | .fi-book:before { content: "\f11e"; } 339 | .fi-bookmark:before { content: "\f11f"; } 340 | .fi-braille:before { content: "\f120"; } 341 | .fi-burst-new:before { content: "\f121"; } 342 | .fi-burst-sale:before { content: "\f122"; } 343 | .fi-burst:before { content: "\f123"; } 344 | .fi-calendar:before { content: "\f124"; } 345 | .fi-camera:before { content: "\f125"; } 346 | .fi-check:before { content: "\f126"; } 347 | .fi-checkbox:before { content: "\f127"; } 348 | .fi-clipboard-notes:before { content: "\f128"; } 349 | .fi-clipboard-pencil:before { content: "\f129"; } 350 | .fi-clipboard:before { content: "\f12a"; } 351 | .fi-clock:before { content: "\f12b"; } 352 | .fi-closed-caption:before { content: "\f12c"; } 353 | .fi-cloud:before { content: "\f12d"; } 354 | .fi-comment-minus:before { content: "\f12e"; } 355 | .fi-comment-quotes:before { content: "\f12f"; } 356 | .fi-comment-video:before { content: "\f130"; } 357 | .fi-comment:before { content: "\f131"; } 358 | .fi-comments:before { content: "\f132"; } 359 | .fi-compass:before { content: "\f133"; } 360 | .fi-contrast:before { content: "\f134"; } 361 | .fi-credit-card:before { content: "\f135"; } 362 | .fi-crop:before { content: "\f136"; } 363 | .fi-crown:before { content: "\f137"; } 364 | .fi-css3:before { content: "\f138"; } 365 | .fi-database:before { content: "\f139"; } 366 | .fi-die-five:before { content: "\f13a"; } 367 | .fi-die-four:before { content: "\f13b"; } 368 | .fi-die-one:before { content: "\f13c"; } 369 | .fi-die-six:before { content: "\f13d"; } 370 | .fi-die-three:before { content: "\f13e"; } 371 | .fi-die-two:before { content: "\f13f"; } 372 | .fi-dislike:before { content: "\f140"; } 373 | .fi-dollar-bill:before { content: "\f141"; } 374 | .fi-dollar:before { content: "\f142"; } 375 | .fi-download:before { content: "\f143"; } 376 | .fi-eject:before { content: "\f144"; } 377 | .fi-elevator:before { content: "\f145"; } 378 | .fi-euro:before { content: "\f146"; } 379 | .fi-eye:before { content: "\f147"; } 380 | .fi-fast-forward:before { content: "\f148"; } 381 | .fi-female-symbol:before { content: "\f149"; } 382 | .fi-female:before { content: "\f14a"; } 383 | .fi-filter:before { content: "\f14b"; } 384 | .fi-first-aid:before { content: "\f14c"; } 385 | .fi-flag:before { content: "\f14d"; } 386 | .fi-folder-add:before { content: "\f14e"; } 387 | .fi-folder-lock:before { content: "\f14f"; } 388 | .fi-folder:before { content: "\f150"; } 389 | .fi-foot:before { content: "\f151"; } 390 | .fi-foundation:before { content: "\f152"; } 391 | .fi-graph-bar:before { content: "\f153"; } 392 | .fi-graph-horizontal:before { content: "\f154"; } 393 | .fi-graph-pie:before { content: "\f155"; } 394 | .fi-graph-trend:before { content: "\f156"; } 395 | .fi-guide-dog:before { content: "\f157"; } 396 | .fi-hearing-aid:before { content: "\f158"; } 397 | .fi-heart:before { content: "\f159"; } 398 | .fi-home:before { content: "\f15a"; } 399 | .fi-html5:before { content: "\f15b"; } 400 | .fi-indent-less:before { content: "\f15c"; } 401 | .fi-indent-more:before { content: "\f15d"; } 402 | .fi-info:before { content: "\f15e"; } 403 | .fi-italic:before { content: "\f15f"; } 404 | .fi-key:before { content: "\f160"; } 405 | .fi-laptop:before { content: "\f161"; } 406 | .fi-layout:before { content: "\f162"; } 407 | .fi-lightbulb:before { content: "\f163"; } 408 | .fi-like:before { content: "\f164"; } 409 | .fi-link:before { content: "\f165"; } 410 | .fi-list-bullet:before { content: "\f166"; } 411 | .fi-list-number:before { content: "\f167"; } 412 | .fi-list-thumbnails:before { content: "\f168"; } 413 | .fi-list:before { content: "\f169"; } 414 | .fi-lock:before { content: "\f16a"; } 415 | .fi-loop:before { content: "\f16b"; } 416 | .fi-magnifying-glass:before { content: "\f16c"; } 417 | .fi-mail:before { content: "\f16d"; } 418 | .fi-male-female:before { content: "\f16e"; } 419 | .fi-male-symbol:before { content: "\f16f"; } 420 | .fi-male:before { content: "\f170"; } 421 | .fi-map:before { content: "\f171"; } 422 | .fi-marker:before { content: "\f172"; } 423 | .fi-megaphone:before { content: "\f173"; } 424 | .fi-microphone:before { content: "\f174"; } 425 | .fi-minus-circle:before { content: "\f175"; } 426 | .fi-minus:before { content: "\f176"; } 427 | .fi-mobile-signal:before { content: "\f177"; } 428 | .fi-mobile:before { content: "\f178"; } 429 | .fi-monitor:before { content: "\f179"; } 430 | .fi-mountains:before { content: "\f17a"; } 431 | .fi-music:before { content: "\f17b"; } 432 | .fi-next:before { content: "\f17c"; } 433 | .fi-no-dogs:before { content: "\f17d"; } 434 | .fi-no-smoking:before { content: "\f17e"; } 435 | .fi-page-add:before { content: "\f17f"; } 436 | .fi-page-copy:before { content: "\f180"; } 437 | .fi-page-csv:before { content: "\f181"; } 438 | .fi-page-delete:before { content: "\f182"; } 439 | .fi-page-doc:before { content: "\f183"; } 440 | .fi-page-edit:before { content: "\f184"; } 441 | .fi-page-export-csv:before { content: "\f185"; } 442 | .fi-page-export-doc:before { content: "\f186"; } 443 | .fi-page-export-pdf:before { content: "\f187"; } 444 | .fi-page-export:before { content: "\f188"; } 445 | .fi-page-filled:before { content: "\f189"; } 446 | .fi-page-multiple:before { content: "\f18a"; } 447 | .fi-page-pdf:before { content: "\f18b"; } 448 | .fi-page-remove:before { content: "\f18c"; } 449 | .fi-page-search:before { content: "\f18d"; } 450 | .fi-page:before { content: "\f18e"; } 451 | .fi-paint-bucket:before { content: "\f18f"; } 452 | .fi-paperclip:before { content: "\f190"; } 453 | .fi-pause:before { content: "\f191"; } 454 | .fi-paw:before { content: "\f192"; } 455 | .fi-paypal:before { content: "\f193"; } 456 | .fi-pencil:before { content: "\f194"; } 457 | .fi-photo:before { content: "\f195"; } 458 | .fi-play-circle:before { content: "\f196"; } 459 | .fi-play-video:before { content: "\f197"; } 460 | .fi-play:before { content: "\f198"; } 461 | .fi-plus:before { content: "\f199"; } 462 | .fi-pound:before { content: "\f19a"; } 463 | .fi-power:before { content: "\f19b"; } 464 | .fi-previous:before { content: "\f19c"; } 465 | .fi-price-tag:before { content: "\f19d"; } 466 | .fi-pricetag-multiple:before { content: "\f19e"; } 467 | .fi-print:before { content: "\f19f"; } 468 | .fi-prohibited:before { content: "\f1a0"; } 469 | .fi-projection-screen:before { content: "\f1a1"; } 470 | .fi-puzzle:before { content: "\f1a2"; } 471 | .fi-quote:before { content: "\f1a3"; } 472 | .fi-record:before { content: "\f1a4"; } 473 | .fi-refresh:before { content: "\f1a5"; } 474 | .fi-results-demographics:before { content: "\f1a6"; } 475 | .fi-results:before { content: "\f1a7"; } 476 | .fi-rewind-ten:before { content: "\f1a8"; } 477 | .fi-rewind:before { content: "\f1a9"; } 478 | .fi-rss:before { content: "\f1aa"; } 479 | .fi-safety-cone:before { content: "\f1ab"; } 480 | .fi-save:before { content: "\f1ac"; } 481 | .fi-share:before { content: "\f1ad"; } 482 | .fi-sheriff-badge:before { content: "\f1ae"; } 483 | .fi-shield:before { content: "\f1af"; } 484 | .fi-shopping-bag:before { content: "\f1b0"; } 485 | .fi-shopping-cart:before { content: "\f1b1"; } 486 | .fi-shuffle:before { content: "\f1b2"; } 487 | .fi-skull:before { content: "\f1b3"; } 488 | .fi-social-500px:before { content: "\f1b4"; } 489 | .fi-social-adobe:before { content: "\f1b5"; } 490 | .fi-social-amazon:before { content: "\f1b6"; } 491 | .fi-social-android:before { content: "\f1b7"; } 492 | .fi-social-apple:before { content: "\f1b8"; } 493 | .fi-social-behance:before { content: "\f1b9"; } 494 | .fi-social-bing:before { content: "\f1ba"; } 495 | .fi-social-blogger:before { content: "\f1bb"; } 496 | .fi-social-delicious:before { content: "\f1bc"; } 497 | .fi-social-designer-news:before { content: "\f1bd"; } 498 | .fi-social-deviant-art:before { content: "\f1be"; } 499 | .fi-social-digg:before { content: "\f1bf"; } 500 | .fi-social-dribbble:before { content: "\f1c0"; } 501 | .fi-social-drive:before { content: "\f1c1"; } 502 | .fi-social-dropbox:before { content: "\f1c2"; } 503 | .fi-social-evernote:before { content: "\f1c3"; } 504 | .fi-social-facebook:before { content: "\f1c4"; } 505 | .fi-social-flickr:before { content: "\f1c5"; } 506 | .fi-social-forrst:before { content: "\f1c6"; } 507 | .fi-social-foursquare:before { content: "\f1c7"; } 508 | .fi-social-game-center:before { content: "\f1c8"; } 509 | .fi-social-github:before { content: "\f1c9"; } 510 | .fi-social-google-plus:before { content: "\f1ca"; } 511 | .fi-social-hacker-news:before { content: "\f1cb"; } 512 | .fi-social-hi5:before { content: "\f1cc"; } 513 | .fi-social-instagram:before { content: "\f1cd"; } 514 | .fi-social-joomla:before { content: "\f1ce"; } 515 | .fi-social-lastfm:before { content: "\f1cf"; } 516 | .fi-social-linkedin:before { content: "\f1d0"; } 517 | .fi-social-medium:before { content: "\f1d1"; } 518 | .fi-social-myspace:before { content: "\f1d2"; } 519 | .fi-social-orkut:before { content: "\f1d3"; } 520 | .fi-social-path:before { content: "\f1d4"; } 521 | .fi-social-picasa:before { content: "\f1d5"; } 522 | .fi-social-pinterest:before { content: "\f1d6"; } 523 | .fi-social-rdio:before { content: "\f1d7"; } 524 | .fi-social-reddit:before { content: "\f1d8"; } 525 | .fi-social-skillshare:before { content: "\f1d9"; } 526 | .fi-social-skype:before { content: "\f1da"; } 527 | .fi-social-smashing-mag:before { content: "\f1db"; } 528 | .fi-social-snapchat:before { content: "\f1dc"; } 529 | .fi-social-spotify:before { content: "\f1dd"; } 530 | .fi-social-squidoo:before { content: "\f1de"; } 531 | .fi-social-stack-overflow:before { content: "\f1df"; } 532 | .fi-social-steam:before { content: "\f1e0"; } 533 | .fi-social-stumbleupon:before { content: "\f1e1"; } 534 | .fi-social-treehouse:before { content: "\f1e2"; } 535 | .fi-social-tumblr:before { content: "\f1e3"; } 536 | .fi-social-twitter:before { content: "\f1e4"; } 537 | .fi-social-vimeo:before { content: "\f1e5"; } 538 | .fi-social-windows:before { content: "\f1e6"; } 539 | .fi-social-xbox:before { content: "\f1e7"; } 540 | .fi-social-yahoo:before { content: "\f1e8"; } 541 | .fi-social-yelp:before { content: "\f1e9"; } 542 | .fi-social-youtube:before { content: "\f1ea"; } 543 | .fi-social-zerply:before { content: "\f1eb"; } 544 | .fi-social-zurb:before { content: "\f1ec"; } 545 | .fi-sound:before { content: "\f1ed"; } 546 | .fi-star:before { content: "\f1ee"; } 547 | .fi-stop:before { content: "\f1ef"; } 548 | .fi-strikethrough:before { content: "\f1f0"; } 549 | .fi-subscript:before { content: "\f1f1"; } 550 | .fi-superscript:before { content: "\f1f2"; } 551 | .fi-tablet-landscape:before { content: "\f1f3"; } 552 | .fi-tablet-portrait:before { content: "\f1f4"; } 553 | .fi-target-two:before { content: "\f1f5"; } 554 | .fi-target:before { content: "\f1f6"; } 555 | .fi-telephone-accessible:before { content: "\f1f7"; } 556 | .fi-telephone:before { content: "\f1f8"; } 557 | .fi-text-color:before { content: "\f1f9"; } 558 | .fi-thumbnails:before { content: "\f1fa"; } 559 | .fi-ticket:before { content: "\f1fb"; } 560 | .fi-torso-business:before { content: "\f1fc"; } 561 | .fi-torso-female:before { content: "\f1fd"; } 562 | .fi-torso:before { content: "\f1fe"; } 563 | .fi-torsos-all-female:before { content: "\f1ff"; } 564 | .fi-torsos-all:before { content: "\f200"; } 565 | .fi-torsos-female-male:before { content: "\f201"; } 566 | .fi-torsos-male-female:before { content: "\f202"; } 567 | .fi-torsos:before { content: "\f203"; } 568 | .fi-trash:before { content: "\f204"; } 569 | .fi-trees:before { content: "\f205"; } 570 | .fi-trophy:before { content: "\f206"; } 571 | .fi-underline:before { content: "\f207"; } 572 | .fi-universal-access:before { content: "\f208"; } 573 | .fi-unlink:before { content: "\f209"; } 574 | .fi-unlock:before { content: "\f20a"; } 575 | .fi-upload-cloud:before { content: "\f20b"; } 576 | .fi-upload:before { content: "\f20c"; } 577 | .fi-usb:before { content: "\f20d"; } 578 | .fi-video:before { content: "\f20e"; } 579 | .fi-volume-none:before { content: "\f20f"; } 580 | .fi-volume-strike:before { content: "\f210"; } 581 | .fi-volume:before { content: "\f211"; } 582 | .fi-web:before { content: "\f212"; } 583 | .fi-wheelchair:before { content: "\f213"; } 584 | .fi-widget:before { content: "\f214"; } 585 | .fi-wrench:before { content: "\f215"; } 586 | .fi-x-circle:before { content: "\f216"; } 587 | .fi-x:before { content: "\f217"; } 588 | .fi-yen:before { content: "\f218"; } 589 | .fi-zoom-in:before { content: "\f219"; } 590 | .fi-zoom-out:before { content: "\f21a"; } 591 | -------------------------------------------------------------------------------- /web/static/css/foundation-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/goauth2/4806e31e4f6b513ce4f95de27b2c8cd007b68eb7/web/static/css/foundation-icons.woff -------------------------------------------------------------------------------- /web/static/css/site.css: -------------------------------------------------------------------------------- 1 | #header, #header a { 2 | color: #F2F5F6; 3 | } 4 | 5 | #header, #header .menu { 6 | background-color: #4AB8C6; 7 | } 8 | 9 | #content { 10 | margin-top: 1em; 11 | } 12 | 13 | #footer { 14 | margin-top: 2em; 15 | } 16 | 17 | .top-bar { 18 | padding: 0 10px; 19 | } 20 | 21 | .top-bar-title { 22 | font-size: 24px; 23 | line-height: 28px; 24 | } 25 | 26 | .top-bar-title img { 27 | vertical-align: bottom; 28 | } 29 | 30 | .large{ font-size: 50px; line-height: 50px; } 31 | .medium { font-size: 35px; padding: 0 5px; } 32 | .small { font-size: 20px; padding: 0 5px; } 33 | -------------------------------------------------------------------------------- /web/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/goauth2/4806e31e4f6b513ce4f95de27b2c8cd007b68eb7/web/static/img/favicon.ico -------------------------------------------------------------------------------- /web/static/img/goauth2-logo-white-30x30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/goauth2/4806e31e4f6b513ce4f95de27b2c8cd007b68eb7/web/static/img/goauth2-logo-white-30x30.png -------------------------------------------------------------------------------- /web/templates/admin/add-user.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/inklabs/goauth2/web.addUserTemplateVars*/ -}} 2 | 3 | {{template "base" .}} 4 | {{define "pageTitle"}}Add User{{end}} 5 | 6 | {{define "content"}} 7 |
8 |
9 |
10 |

Add User

11 |
12 | 13 |
14 |
15 | {{.CSRFField}} 16 |
17 |
18 |
19 | 22 |
23 |
24 | 27 |
28 |
29 | 32 |
33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {{end}} 43 | -------------------------------------------------------------------------------- /web/templates/admin/list-client-applications.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/inklabs/goauth2/web.listClientApplicationsTemplateVars*/ -}} 2 | 3 | {{template "base" .}} 4 | {{define "pageTitle"}}Client Applications{{end}} 5 | 6 | {{define "content"}} 7 |
8 |
9 |
10 |

Client Applications

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {{range .ClientApplications}} 21 | 22 | 23 | 24 | 25 | 26 | {{end}} 27 | 28 |
Client IDClient SecretCreation Date
{{.ClientID}}{{.ClientSecret}}{{formatDate .CreateTimestamp "Jan 02, 2006 15:04:05 UTC"}}
29 |
    30 |
31 |
32 |
33 |
34 | {{end}} 35 | -------------------------------------------------------------------------------- /web/templates/admin/list-users.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/inklabs/goauth2/web.listUsersTemplateVars*/ -}} 2 | 3 | {{template "base" .}} 4 | {{define "pageTitle"}}Users{{end}} 5 | 6 | {{define "content"}} 7 |
8 |
9 |
10 |

Users

11 |
12 |
13 | Add 14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {{range .Users}} 27 | 28 | 29 | 30 | 31 | 35 | 36 | {{end}} 37 | 38 |
User IDUsernameCreation DateRoles
{{.UserID}}{{.Username}}{{formatDate .CreateTimestamp "Jan 02, 2006 15:04:05 UTC"}} 32 | {{if .IsAdmin}}{{end}} 33 | {{if .CanOnboardAdminApplications}}{{end}} 34 |
39 |
40 |
41 |
42 | {{end}} 43 | -------------------------------------------------------------------------------- /web/templates/admin/login.gohtml: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | {{define "pageTitle"}}Admin Login{{end}} 3 | 4 | {{define "content"}} 5 |
6 |
7 |
8 |

Admin Login

9 |
10 |
11 | 35 |
36 |
37 |
38 | {{end}} 39 | -------------------------------------------------------------------------------- /web/templates/layout/base.gohtml: -------------------------------------------------------------------------------- 1 | {{define "base"}} 2 | 3 | 4 | 5 | 6 | 7 | {{block "pageTitle" .}}{{end}} - GoAuth2 8 | 9 | 10 | 11 | 12 | 13 | {{block "extraHead" .}}{{end}} 14 | 15 | 16 | 32 | 33 | {{block "flash" .}} 34 | {{ if index .Errors }} 35 |
36 | {{ range index .Errors }} 37 |

{{ . }}

38 | {{ end }} 39 |
40 | {{ end }} 41 | {{ if index .Messages }} 42 |
43 | {{ range index .Messages }} 44 |

{{ . }}

45 | {{ end }} 46 |
47 | {{ end }} 48 | {{end}} 49 | 50 |
51 | {{block "content" .}}{{end}} 52 |
53 | 54 | 64 | 65 | {{block "extraEndBody" .}}{{end}} 66 | 67 | 68 | 69 | {{end}} 70 | -------------------------------------------------------------------------------- /web/templates/login.gohtml: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | {{define "pageTitle"}}Login{{end}} 3 | 4 | {{define "content"}} 5 |
6 |
7 |
8 |

Log In To Your Account

9 |
10 |
11 | 40 |
41 |
42 |
43 | {{end}} 44 | -------------------------------------------------------------------------------- /web/web_app.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "encoding/gob" 6 | "encoding/json" 7 | "fmt" 8 | "html/template" 9 | "io/fs" 10 | "log" 11 | "net/http" 12 | "net/url" 13 | "path" 14 | "strconv" 15 | "time" 16 | 17 | "github.com/gorilla/mux" 18 | "github.com/gorilla/sessions" 19 | "github.com/inklabs/rangedb/pkg/shortuuid" 20 | 21 | "github.com/inklabs/goauth2" 22 | "github.com/inklabs/goauth2/projection" 23 | ) 24 | 25 | const ( 26 | accessTokenTODO = "f5bb89d486ee458085e476871b177ff4" 27 | ClientIDTODO = "8895e1e5f06644ebb41c26ea5740b246" 28 | ClientSecretTODO = "c1e847aef925467290b4302e64f3de4e" 29 | expiresAtTODO = 1574371565 30 | ) 31 | 32 | //go:embed static 33 | var staticAssets embed.FS 34 | 35 | //go:embed templates 36 | var templates embed.FS 37 | 38 | const defaultHost = "0.0.0.0:8080" 39 | 40 | // SessionKeyPair holds the keys for a secure cookie session. 41 | type SessionKeyPair struct { 42 | AuthenticationKey []byte 43 | EncryptionKey []byte 44 | } 45 | 46 | type webApp struct { 47 | router http.Handler 48 | templateFS fs.FS 49 | goAuth2App *goauth2.App 50 | uuidGenerator shortuuid.Generator 51 | sessionStore sessions.Store 52 | sessionKeyPairs []SessionKeyPair 53 | csrfAuthKey []byte 54 | host string 55 | projections struct { 56 | emailToUserID *projection.EmailToUserID 57 | clientApplications *projection.ClientApplications 58 | users *projection.Users 59 | } 60 | } 61 | 62 | // Option defines functional option parameters for webApp. 63 | type Option func(*webApp) 64 | 65 | // WithTemplateFS is a functional option to inject a fs.FS 66 | func WithTemplateFS(f fs.FS) Option { 67 | return func(webApp *webApp) { 68 | webApp.templateFS = f 69 | } 70 | } 71 | 72 | // WithGoAuth2App is a functional option to inject a goauth2.App. 73 | func WithGoAuth2App(goAuth2App *goauth2.App) Option { 74 | return func(app *webApp) { 75 | app.goAuth2App = goAuth2App 76 | } 77 | } 78 | 79 | // WithHost is a functional option to inject a tcp4 host. 80 | func WithHost(host string) Option { 81 | return func(app *webApp) { 82 | app.host = host 83 | } 84 | } 85 | 86 | // WithUUIDGenerator is a functional option to inject a shortuuid.Generator. 87 | func WithUUIDGenerator(generator shortuuid.Generator) Option { 88 | return func(app *webApp) { 89 | app.uuidGenerator = generator 90 | } 91 | } 92 | 93 | // WithCSRFAuthKey is a functional option to inject a CSRF authentication key 94 | func WithCSRFAuthKey(csrfAuthKey []byte) Option { 95 | return func(app *webApp) { 96 | app.csrfAuthKey = csrfAuthKey 97 | } 98 | } 99 | 100 | // WithSessionKeyPair is a functional option to inject a session key pair. 101 | // Useful for rotating session authentication and encryption keys. Old sessions can still 102 | // be read because the first pair will fail, and the second will be tested. 103 | func WithSessionKeyPair(sessionKeyPairs ...SessionKeyPair) Option { 104 | return func(app *webApp) { 105 | app.sessionKeyPairs = sessionKeyPairs 106 | } 107 | } 108 | 109 | // New constructs an webApp. 110 | func New(options ...Option) (*webApp, error) { 111 | goAuth2App, err := goauth2.New() 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | app := &webApp{ 117 | templateFS: templates, 118 | goAuth2App: goAuth2App, 119 | uuidGenerator: shortuuid.NewUUIDGenerator(), 120 | host: defaultHost, 121 | } 122 | 123 | for _, option := range options { 124 | option(app) 125 | } 126 | 127 | err = app.validateCSRFAuthKey() 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | err = app.initSessionStore() 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | app.initRoutes() 138 | err = app.initProjections() 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | return app, nil 144 | } 145 | 146 | func (a *webApp) validateCSRFAuthKey() error { 147 | if a.csrfAuthKey == nil { 148 | return fmt.Errorf("missing CSRF authentication key") 149 | } 150 | 151 | if len(a.csrfAuthKey) != 32 { 152 | return fmt.Errorf("invalid CSRF authentication key length") 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func (a *webApp) initSessionStore() error { 159 | var keyPairs [][]byte 160 | 161 | for _, sessionKeyPair := range a.sessionKeyPairs { 162 | if len(sessionKeyPair.AuthenticationKey) != 64 { 163 | return fmt.Errorf("invalid session authentication key length") 164 | } 165 | 166 | if len(sessionKeyPair.EncryptionKey) != 32 { 167 | return fmt.Errorf("invalid session encryption key length") 168 | } 169 | 170 | keyPairs = append(keyPairs, 171 | sessionKeyPair.AuthenticationKey, 172 | sessionKeyPair.EncryptionKey, 173 | ) 174 | } 175 | 176 | gob.Register(AuthenticatedUser{}) 177 | a.sessionStore = sessions.NewCookieStore(keyPairs...) 178 | return nil 179 | } 180 | 181 | func (a *webApp) initRoutes() { 182 | r := mux.NewRouter().StrictSlash(true) 183 | r.HandleFunc("/authorize", a.authorize) 184 | r.HandleFunc("/login", a.login) 185 | r.HandleFunc("/token", a.token) 186 | r.PathPrefix("/static/").Handler(cache30Days(http.FileServer(http.FS(staticAssets)))) 187 | 188 | a.addAdminRoutes(r) 189 | 190 | a.router = r 191 | } 192 | 193 | func (a *webApp) initProjections() error { 194 | a.projections.emailToUserID = projection.NewEmailToUserID() 195 | a.projections.clientApplications = projection.NewClientApplications() 196 | a.projections.users = projection.NewUsers() 197 | 198 | return a.goAuth2App.SubscribeAndReplay( 199 | a.projections.emailToUserID, 200 | a.projections.clientApplications, 201 | a.projections.users, 202 | ) 203 | } 204 | 205 | func (a *webApp) ServeHTTP(w http.ResponseWriter, r *http.Request) { 206 | a.router.ServeHTTP(w, r) 207 | } 208 | 209 | func (a *webApp) login(w http.ResponseWriter, r *http.Request) { 210 | params := r.URL.Query() 211 | clientID := params.Get("client_id") 212 | redirectURI := params.Get("redirect_uri") 213 | responseType := params.Get("response_type") 214 | state := params.Get("state") 215 | scope := params.Get("scope") 216 | 217 | a.renderTemplate(w, "login.gohtml", struct { 218 | flashMessageVars 219 | ClientID string 220 | RedirectURI string 221 | ResponseType string 222 | Scope string 223 | State string 224 | }{ 225 | ClientID: clientID, 226 | RedirectURI: redirectURI, 227 | ResponseType: responseType, 228 | Scope: scope, 229 | State: state, 230 | flashMessageVars: a.getFlashMessageVars(w, r), 231 | }) 232 | } 233 | 234 | func (a *webApp) authorize(w http.ResponseWriter, r *http.Request) { 235 | err := r.ParseForm() 236 | if err != nil { 237 | writeInvalidRequestResponse(w) 238 | return 239 | } 240 | 241 | redirectURI := r.Form.Get("redirect_uri") 242 | responseType := r.Form.Get("response_type") 243 | state := r.Form.Get("state") 244 | 245 | switch responseType { 246 | case "code": 247 | a.handleAuthorizationCodeGrant(w, r) 248 | 249 | case "token": 250 | a.handleImplicitGrant(w, r) 251 | 252 | default: 253 | errorRedirect(w, r, redirectURI, "unsupported_response_type", state) 254 | 255 | } 256 | } 257 | 258 | func (a *webApp) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { 259 | username := r.Form.Get("username") 260 | password := r.Form.Get("password") 261 | clientID := r.Form.Get("client_id") 262 | redirectURI := r.Form.Get("redirect_uri") 263 | state := r.Form.Get("state") 264 | scope := r.Form.Get("scope") 265 | 266 | userID, err := a.projections.emailToUserID.GetUserID(username) 267 | if err != nil { 268 | errorRedirect(w, r, redirectURI, "access_denied", state) 269 | return 270 | } 271 | 272 | // TODO: Change signature for Dispatch from []rangedb.Event to SavedEvents 273 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.RequestAuthorizationCodeViaAuthorizationCodeGrant{ 274 | UserID: userID, 275 | ClientID: clientID, 276 | RedirectURI: redirectURI, 277 | Username: username, 278 | Password: password, 279 | Scope: scope, 280 | })) 281 | var issuedEvent goauth2.AuthorizationCodeWasIssuedToUserViaAuthorizationCodeGrant 282 | if !events.Get(&issuedEvent) { 283 | if events.ContainsAny( 284 | &goauth2.RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationID{}, 285 | &goauth2.RequestAuthorizationCodeViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationRedirectURI{}, 286 | ) { 287 | writeInvalidRequestResponse(w) 288 | return 289 | } 290 | 291 | errorRedirect(w, r, redirectURI, "access_denied", state) 292 | return 293 | } 294 | 295 | newParams := url.Values{} 296 | if state != "" { 297 | newParams.Set("state", state) 298 | } 299 | 300 | newParams.Set("code", issuedEvent.AuthorizationCode) 301 | 302 | uri := fmt.Sprintf("%s?%s", redirectURI, newParams.Encode()) 303 | http.Redirect(w, r, uri, http.StatusFound) 304 | } 305 | 306 | func (a *webApp) handleImplicitGrant(w http.ResponseWriter, r *http.Request) { 307 | username := r.Form.Get("username") 308 | password := r.Form.Get("password") 309 | clientID := r.Form.Get("client_id") 310 | redirectURI := r.Form.Get("redirect_uri") 311 | state := r.Form.Get("state") 312 | scope := r.Form.Get("scope") 313 | 314 | userID, err := a.projections.emailToUserID.GetUserID(username) 315 | if err != nil { 316 | errorRedirect(w, r, redirectURI, "access_denied", state) 317 | return 318 | } 319 | 320 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.RequestAccessTokenViaImplicitGrant{ 321 | UserID: userID, 322 | ClientID: clientID, 323 | RedirectURI: redirectURI, 324 | Username: username, 325 | Password: password, 326 | })) 327 | var issuedEvent goauth2.AccessTokenWasIssuedToUserViaImplicitGrant 328 | if !events.Get(&issuedEvent) { 329 | if events.ContainsAny( 330 | &goauth2.RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidClientApplicationID{}, 331 | &goauth2.RequestAccessTokenViaImplicitGrantWasRejectedDueToInvalidClientApplicationRedirectURI{}, 332 | ) { 333 | writeInvalidRequestResponse(w) 334 | return 335 | } 336 | 337 | errorRedirect(w, r, redirectURI, "access_denied", state) 338 | return 339 | } 340 | 341 | newParams := url.Values{} 342 | if state != "" { 343 | newParams.Set("state", state) 344 | } 345 | 346 | newParams.Set("access_token", accessTokenTODO) 347 | newParams.Set("expires_at", strconv.Itoa(expiresAtTODO)) 348 | newParams.Set("scope", scope) 349 | newParams.Set("token_type", "Bearer") 350 | 351 | uri := fmt.Sprintf("%s#%s", redirectURI, newParams.Encode()) 352 | http.Redirect(w, r, uri, http.StatusFound) 353 | } 354 | 355 | // AccessTokenResponse holds the JSON response for an access token. 356 | type AccessTokenResponse struct { 357 | AccessToken string `json:"access_token"` 358 | ExpiresAt int64 `json:"expires_at"` 359 | TokenType string `json:"token_type"` 360 | RefreshToken string `json:"refresh_token,omitempty"` 361 | Scope string `json:"scope,omitempty"` 362 | } 363 | 364 | func (a *webApp) token(w http.ResponseWriter, r *http.Request) { 365 | clientID, clientSecret, ok := r.BasicAuth() 366 | if !ok { 367 | writeInvalidClientResponse(w) 368 | return 369 | } 370 | 371 | err := r.ParseForm() 372 | if err != nil { 373 | writeInvalidRequestResponse(w) 374 | return 375 | } 376 | 377 | grantType := r.Form.Get("grant_type") 378 | scope := r.Form.Get("scope") 379 | 380 | switch grantType { 381 | case "client_credentials": 382 | a.handleClientCredentialsGrant(w, clientID, clientSecret, scope) 383 | 384 | case "password": 385 | a.handleROPCGrant(w, r, clientID, clientSecret, scope) 386 | 387 | case "refresh_token": 388 | a.handleRefreshTokenGrant(w, r, clientID, clientSecret, scope) 389 | 390 | case "authorization_code": 391 | a.handleAuthorizationCodeTokenGrant(w, r, clientID, clientSecret) 392 | 393 | default: 394 | writeUnsupportedGrantTypeResponse(w) 395 | } 396 | } 397 | 398 | func (a *webApp) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request, clientID, clientSecret, scope string) { 399 | refreshToken := r.Form.Get("refresh_token") 400 | 401 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.RequestAccessTokenViaRefreshTokenGrant{ 402 | RefreshToken: refreshToken, 403 | ClientID: clientID, 404 | ClientSecret: clientSecret, 405 | Scope: scope, 406 | })) 407 | 408 | var accessTokenEvent goauth2.AccessTokenWasIssuedToUserViaRefreshTokenGrant 409 | if !events.Get(&accessTokenEvent) { 410 | writeInvalidGrantResponse(w) 411 | return 412 | } 413 | 414 | var nextRefreshToken string 415 | var refreshTokenEvent goauth2.RefreshTokenWasIssuedToUserViaRefreshTokenGrant 416 | if events.Get(&refreshTokenEvent) { 417 | nextRefreshToken = refreshTokenEvent.NextRefreshToken 418 | } 419 | 420 | writeJSONResponse(w, AccessTokenResponse{ 421 | AccessToken: "61272356284f4340b2b1f3f1400ad4d9", 422 | ExpiresAt: accessTokenEvent.ExpiresAt, 423 | TokenType: "Bearer", 424 | RefreshToken: nextRefreshToken, 425 | Scope: accessTokenEvent.Scope, 426 | }) 427 | return 428 | } 429 | 430 | func (a *webApp) handleROPCGrant(w http.ResponseWriter, r *http.Request, clientID, clientSecret, scope string) { 431 | username := r.Form.Get("username") 432 | password := r.Form.Get("password") 433 | userID, err := a.projections.emailToUserID.GetUserID(username) 434 | if err != nil { 435 | writeInvalidGrantResponse(w) 436 | return 437 | } 438 | 439 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.RequestAccessTokenViaROPCGrant{ 440 | UserID: userID, 441 | ClientID: clientID, 442 | ClientSecret: clientSecret, 443 | Username: username, 444 | Password: password, 445 | Scope: scope, 446 | })) 447 | var accessTokenEvent goauth2.AccessTokenWasIssuedToUserViaROPCGrant 448 | if !events.Get(&accessTokenEvent) { 449 | if events.Contains(&goauth2.RequestAccessTokenViaROPCGrantWasRejectedDueToInvalidClientApplicationCredentials{}) { 450 | writeInvalidClientResponse(w) 451 | return 452 | } 453 | 454 | writeInvalidGrantResponse(w) 455 | return 456 | } 457 | 458 | var refreshToken string 459 | var refreshTokenEvent goauth2.RefreshTokenWasIssuedToUserViaROPCGrant 460 | if events.Get(&refreshTokenEvent) { 461 | refreshToken = refreshTokenEvent.RefreshToken 462 | } 463 | 464 | writeJSONResponse(w, AccessTokenResponse{ 465 | AccessToken: accessTokenTODO, 466 | ExpiresAt: accessTokenEvent.ExpiresAt, 467 | TokenType: "Bearer", 468 | RefreshToken: refreshToken, 469 | Scope: accessTokenEvent.Scope, 470 | }) 471 | return 472 | } 473 | 474 | func (a *webApp) handleClientCredentialsGrant(w http.ResponseWriter, clientID, clientSecret, scope string) { 475 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.RequestAccessTokenViaClientCredentialsGrant{ 476 | ClientID: clientID, 477 | ClientSecret: clientSecret, 478 | Scope: scope, 479 | })) 480 | var accessTokenIssuedEvent goauth2.AccessTokenWasIssuedToClientApplicationViaClientCredentialsGrant 481 | if !events.Get(&accessTokenIssuedEvent) { 482 | writeInvalidClientResponse(w) 483 | return 484 | } 485 | 486 | writeJSONResponse(w, AccessTokenResponse{ 487 | AccessToken: accessTokenTODO, 488 | ExpiresAt: accessTokenIssuedEvent.ExpiresAt, 489 | TokenType: "Bearer", 490 | Scope: accessTokenIssuedEvent.Scope, 491 | }) 492 | return 493 | } 494 | 495 | func (a *webApp) handleAuthorizationCodeTokenGrant(w http.ResponseWriter, r *http.Request, clientID, clientSecret string) { 496 | authorizationCode := r.Form.Get("code") 497 | redirectURI := r.Form.Get("redirect_uri") 498 | 499 | events := SavedEvents(a.goAuth2App.Dispatch(goauth2.RequestAccessTokenViaAuthorizationCodeGrant{ 500 | AuthorizationCode: authorizationCode, 501 | ClientID: clientID, 502 | ClientSecret: clientSecret, 503 | RedirectURI: redirectURI, 504 | })) 505 | 506 | var accessTokenIssuedEvent goauth2.AccessTokenWasIssuedToUserViaAuthorizationCodeGrant 507 | if !events.Get(&accessTokenIssuedEvent) { 508 | if events.ContainsAny( 509 | &goauth2.RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationID{}, 510 | &goauth2.RequestAccessTokenViaAuthorizationCodeGrantWasRejectedDueToInvalidClientApplicationSecret{}, 511 | ) { 512 | writeInvalidClientResponse(w) 513 | return 514 | } 515 | 516 | writeInvalidGrantResponse(w) 517 | return 518 | } 519 | 520 | var refreshToken string 521 | var refreshTokenIssuedEvent goauth2.RefreshTokenWasIssuedToUserViaAuthorizationCodeGrant 522 | if events.Get(&refreshTokenIssuedEvent) { 523 | refreshToken = refreshTokenIssuedEvent.RefreshToken 524 | } 525 | 526 | writeJSONResponse(w, AccessTokenResponse{ 527 | AccessToken: accessTokenTODO, 528 | ExpiresAt: accessTokenIssuedEvent.ExpiresAt, 529 | TokenType: "Bearer", 530 | Scope: accessTokenIssuedEvent.Scope, 531 | RefreshToken: refreshToken, 532 | }) 533 | return 534 | } 535 | 536 | func (a *webApp) renderTemplate(w http.ResponseWriter, templateName string, data interface{}) { 537 | baseTemplateName := path.Base(templateName) 538 | tmpl, err := template.New(baseTemplateName).Funcs(funcMap).ParseFS(a.templateFS, "templates/layout/*.gohtml", "templates/"+templateName) 539 | if err != nil { 540 | log.Printf("unable to parse template %s: %v", templateName, err) 541 | http.Error(w, "internal error", http.StatusInternalServerError) 542 | return 543 | } 544 | 545 | err = tmpl.Execute(w, data) 546 | if err != nil { 547 | log.Printf("unable to render template %s: %v", templateName, err) 548 | http.Error(w, "500 Internal Server Error", http.StatusInternalServerError) 549 | return 550 | } 551 | } 552 | 553 | type errorResponse struct { 554 | Error string `json:"error"` 555 | } 556 | 557 | func writeInvalidRequestResponse(w http.ResponseWriter) { 558 | http.Error(w, "invalid request", http.StatusBadRequest) 559 | } 560 | 561 | func writeInternalServerErrorResponse(w http.ResponseWriter) { 562 | http.Error(w, "invalid request", http.StatusInternalServerError) 563 | } 564 | 565 | func writeInvalidClientResponse(w http.ResponseWriter) { 566 | w.WriteHeader(http.StatusUnauthorized) 567 | writeJSONResponse(w, errorResponse{Error: "invalid_client"}) 568 | } 569 | 570 | func writeInvalidGrantResponse(w http.ResponseWriter) { 571 | w.WriteHeader(http.StatusUnauthorized) 572 | writeJSONResponse(w, errorResponse{Error: "invalid_grant"}) 573 | } 574 | 575 | func writeUnsupportedGrantTypeResponse(w http.ResponseWriter) { 576 | w.WriteHeader(http.StatusBadRequest) 577 | writeJSONResponse(w, errorResponse{Error: "unsupported_grant_type"}) 578 | } 579 | 580 | func writeJSONResponse(w http.ResponseWriter, jsonBody interface{}) { 581 | bytes, err := json.Marshal(jsonBody) 582 | if err != nil { 583 | http.Error(w, "internal error", http.StatusInternalServerError) 584 | return 585 | } 586 | w.Header().Set("Content-Type", "application/json;charset=UTF-8") 587 | w.Header().Set("Cache-Control", "no-store") 588 | w.Header().Set("Pragma", "no-cache") 589 | _, _ = w.Write(bytes) 590 | } 591 | 592 | func errorRedirect(w http.ResponseWriter, r *http.Request, redirectURI, errorMessage, state string) { 593 | query := url.Values{} 594 | query.Set("error", errorMessage) 595 | 596 | if state != "" { 597 | query.Set("state", state) 598 | } 599 | uri := fmt.Sprintf("%s?%s", redirectURI, query.Encode()) 600 | http.Redirect(w, r, uri, http.StatusFound) 601 | } 602 | 603 | func cache30Days(s http.Handler) http.Handler { 604 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 605 | thirtyDays := time.Hour * 24 * 30 606 | w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", thirtyDays)) 607 | s.ServeHTTP(w, r) 608 | }) 609 | } 610 | -------------------------------------------------------------------------------- /web/web_app_private_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "math" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func Test_writeJsonResponse_FailsFromInvalidJson(t *testing.T) { 14 | // Given 15 | w := httptest.NewRecorder() 16 | invalidJSON := map[string]float64{ 17 | "foo": math.Inf(1), 18 | } 19 | 20 | // When 21 | writeJSONResponse(w, invalidJSON) 22 | 23 | // Then 24 | body := w.Body.String() 25 | require.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) 26 | assert.Equal(t, "HTTP/1.1", w.Result().Proto) 27 | assert.Contains(t, body, "internal error") 28 | } 29 | -------------------------------------------------------------------------------- /web/web_app_session.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | sessionName = "goa2" 10 | flashMessageKey = "flash.message" 11 | flashErrorKey = "flash.error" 12 | ) 13 | 14 | func (a *webApp) FlashError(w http.ResponseWriter, r *http.Request, format string, vars ...interface{}) { 15 | a.flashMessage(w, r, flashErrorKey, fmt.Sprintf(format, vars...)) 16 | } 17 | 18 | func (a *webApp) FlashMessage(w http.ResponseWriter, r *http.Request, format string, vars ...interface{}) { 19 | a.flashMessage(w, r, flashMessageKey, fmt.Sprintf(format, vars...)) 20 | } 21 | 22 | func (a *webApp) flashMessage(w http.ResponseWriter, r *http.Request, key, message string) { 23 | session, _ := a.sessionStore.Get(r, sessionName) 24 | session.AddFlash(message, key) 25 | _ = session.Save(r, w) 26 | } 27 | 28 | func (a *webApp) getFlashMessageVars(w http.ResponseWriter, r *http.Request) flashMessageVars { 29 | session, _ := a.sessionStore.Get(r, sessionName) 30 | fErrors := session.Flashes(flashErrorKey) 31 | fMessages := session.Flashes(flashMessageKey) 32 | 33 | var flashErrors, flashMessages []string 34 | for _, flash := range fErrors { 35 | flashErrors = append(flashErrors, flash.(string)) 36 | } 37 | for _, flash := range fMessages { 38 | flashMessages = append(flashMessages, flash.(string)) 39 | } 40 | 41 | if len(fErrors) > 0 || len(fMessages) > 0 { 42 | _ = session.Save(r, w) 43 | } 44 | 45 | return flashMessageVars{ 46 | Errors: flashErrors, 47 | Messages: flashMessages, 48 | } 49 | } 50 | --------------------------------------------------------------------------------