├── .gitignore ├── CREDITS ├── LICENSE ├── README.md ├── README_ja.md ├── adapter_github.go ├── adapter_oidc.go ├── adapter_twitter.go ├── cmd ├── sampleapp │ ├── .gitignore │ └── main.go └── wru │ ├── .gitignore │ └── main.go ├── config.go ├── config_test.go ├── doc ├── dialogs-dev-mode.png ├── dialogs-production-mode.png ├── dialogs-session-storage.png ├── dialogs.drawio ├── logo-small.png └── logo.png ├── go.mod ├── go.sum ├── handler.go ├── handler_helper.go ├── identityregister.go ├── identityregister_test.go ├── middleware.go ├── reverseproxy.go ├── reverseproxy_test.go ├── serverless_sessionstorage.go ├── session.go ├── sessionstorage.go ├── sessionstorage_test.go ├── templates.go ├── templates ├── debug_login.html ├── login.html ├── user_sessions.html └── user_status.html ├── testdata ├── keycloak_wrusample_realm.json ├── launch_keycloak.sh ├── testserver.go ├── testuser.csv ├── testuser_for_ut.csv └── write_csv.py ├── testhelper.go └── tools.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | .DS_Store 3 | Thumbds.db 4 | .env* 5 | GeoLite2-Country.mmdb 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 osaki-lab 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WRU: who are you 2 | 3 | ![wru logo](doc/logo-small.png) 4 | 5 | WRU is an identity aware reverse proxy/middleware for enterprise users. 6 | It provides seamless authentication experience between development environemnt and production environment. 7 | 8 | ## What is WRU for 9 | 10 | - For enterprise users 11 | 12 | - Easy to inject users via CSV files on storage (local, AWS S3, GCP Cloud Storage) 13 | 14 | - Not For consumer service users 15 | 16 | - It is not supporting creating user 17 | 18 | - For testing 19 | 20 | - Inject user information from env vars/files. You can setup via Docker easily 21 | - No password required (E2E test friendly) 22 | 23 | - For production 24 | - It supports OpenID Connect, some SNS (Twitter and GitHub for now) to login. 25 | 26 | ## Use as Reverse Proxy 27 | 28 | ### Getting Started 29 | 30 | You can get wru by "go get". 31 | 32 | ```bash 33 | $ go get -u github.com/future-architect/future-wru/cmd/wru 34 | $ wru 35 | Port: 8000 36 | TLS: enabled 37 | Debug: false 38 | Forward To: 39 | / => http://localhost:8080 () 40 | Twitter Login: OK 41 | GitHub Login: OK 42 | Users (for Debug): 43 | (User) 'test user 1'(user1) @ R&D (scopes: admin, user, org:rd) 44 | (User) 'test user 2'(user2) @ HR (scopes: user, org:hr) 45 | starting wru server at https://localhost:8000 46 | ``` 47 | 48 | `wru` command doesn't have command line options. You can control it via environment variables. 49 | 50 | ### WRU modes 51 | 52 | WRU has two modes. This absorbs the difference of usecases and 53 | your web service always get only authorized requests. 54 | 55 | #### WRU for local development 56 | 57 | ![dev-mode](doc/dialogs-dev-mode.png) 58 | 59 | Sample configuration: 60 | 61 | - Launch at https://localhost:8000 62 | - Enable HTTPS by wru 63 | - A backend server is at http://localhost:8080 64 | - Two test users 65 | - Session storage is in-memory mode (turn off wru resets data) 66 | 67 | ```bash 68 | $ export WRU_DEV_MODE=true 69 | $ export WRU_TLS_CERT="-----BEGIN CERTIFICATE-----\naaaabbbbbcccccdddd....zzzz\n-----END CERTIFICATE-----" 70 | $ export WRU_TLS_KEY="-----BEGIN PRIVATE KEY-----\nZZZZYYYYYYXXXX.....BBBBAAAA\n-----END PRIVATE KEY-----" 71 | $ export WRU_FORWARD_TO="/ => http://localhost:8080" 72 | $ export WRU_USER_1="id:user1,name:test user 1,mail:user1@example.com,org:R&D,scope:admin,scope:user,scope:org:rd,twitter:user1,github:user1" 73 | $ export WRU_USER_2="id:user2,name:test user 2,mail:user2@example.com,org:HR,scope:user,scope:org:hr,twitter:user2,github:user2" 74 | $ PORT=8000 HOST=https://localhost:8000 wru 75 | ``` 76 | 77 | #### WRU for production 78 | 79 | ![production-mode](doc/dialogs-production-mode.png) 80 | 81 | Sample configuration: 82 | 83 | - Launch at example.com (local port is 8000) 84 | - No HTTPS by wru (AWS ALB does) 85 | - A backend server is at http://server.example.com 86 | - User information is in S3 (and reread it every hour) 87 | - Session storage is in DynamoDB 88 | - Twitter/GitHub/OpenID Connect login is available 89 | 90 | ```bash 91 | $ export WRU_DEV_MODE=false 92 | $ export WRU_FORWARD_TO="/ => http://server.example.com" 93 | $ export WRU_USER_TABLE="s3://my-app-usertable/user-list.csv?region=us-west-1" 94 | $ export WRU_USER_TABLE_RELOAD_TERM=1h 95 | $ export WRU_TWITTER_CONSUMER_KEY=1111111 96 | $ export WRU_TWITTER_CONSUMER_SECRET=22222222 97 | $ export WRU_GITHUB_CLIENT_ID=33333333 98 | $ export WRU_GITHUB_CLIENT_SECRET=44444444 99 | $ export WRU_OIDC_PROVIDER_URL=http://keycloak.example.com 100 | $ export WRU_OIDC_CLIENT_ID=55555555 101 | $ export WRU_OIDC_CLIENT_SECRET=66666666 102 | $ PORT=8000 HOST=https://example.com wru 103 | ``` 104 | 105 | ### End Points for frontend 106 | 107 | - `/.wru/login`: Login page 108 | - `/.wru/logout`: Logout page (it works just GET access) 109 | - `/.wru/user`: User page (it supports HTML and JSON) 110 | - `/.wru/user/sessions`: User session page (it supports HTML and JSON) 111 | 112 | ### Session Storage 113 | 114 | It supports session storage feature similar to browsers' cookie. 115 | 116 | ![session storage](doc/dialogs-session-storage.png) 117 | 118 | Your web application sends data that will be in the session storage with in `Wru-Set-Session-Data` header field in response like this: 119 | 120 | ```http 121 | Wru-Set-Session-Data: access-count=10 122 | ``` 123 | 124 | wru filter this content (browser doesn't retrieve this header field) and store its content in session storage. 125 | This content is added to `Wru-Session` header field (you can modify via `WRU_SERVER_SESSION_FIELD` env var) like this: 126 | 127 | ```go 128 | Wru-Session: {"login_at":1212121,"id":"shibu","name":"Yoshiki Shibukawa","scopes":["user","admin"],data:{"access-count":"10"}} 129 | ``` 130 | 131 | To read all content of this field in Go, you can parse it via the following structure: 132 | 133 | ```go 134 | type Session struct { 135 | LoginAt int64 `json:"login_at"` // nano-seconds 136 | ExpireAt int64 `json:"expire_at"` // nano-seconds 137 | LastAccessAt int64 `json:"last_access_at"` // nano-seconds 138 | UserID string `json:"id"` 139 | DisplayName string `json:"name"` 140 | Email string `json:"email"` 141 | Organization string `json:"org"` 142 | Scopes []string `json:"scopes"` 143 | Data map[string]string `json:"data"` 144 | } 145 | 146 | func ParseSession(r *http.Request) (*Session, error) { 147 | h := r.Header.Get("Wru-Session") 148 | if h != "" { 149 | var s Session 150 | err := json.NewDecoder(strings.NewReader(h)).Decode(&s) 151 | if err != nil { 152 | return nil, err 153 | } 154 | return &s, nil 155 | } 156 | return nil, err 157 | } 158 | ``` 159 | 160 | ### Configuration 161 | 162 | #### Server Configuration 163 | 164 | - `PORT`: Port number that wru uses (default is 3000) 165 | - `HOST`: Host name that wru is avaialble (required). It is used for callback of OAuth/OpenID Connect. 166 | - `WRU_DEV_MODE`: Change mode (described bellow) 167 | - `WRU_TLS_CERT` and `WRU_TLS_KEY`: Launch TLS server 168 | 169 | ### Storage Configuration 170 | 171 | WRU stores user information on-memory. You can add user via CSV or env vars. 172 | 173 | - `WRU_SESSION_STORAGE`: Session storage. Default is in memory. It supports DynamoDB, Firestore, MongoDB. 174 | - `WRU_USER_TABLE`: This is local file path/Blob path(AWS S3, GCP Cloud Storage) to read CSV. 175 | - `WRU_USER_TABLE_RELOAD_TERM`: Reload term. 176 | - `WRU_USER_%d`: Add user via environment variable (for testing). 177 | 178 | If you add user via env var, you use comma separated tag list: 179 | 180 | ```bash 181 | WRU_USER_1="id:user1,name:test user 1,mail:user1@example.com,org:R&D,scope:admin,scope:user,scope:org:rd,twitter:user1" 182 | ``` 183 | 184 | User table CSV file should have specific header row. 185 | 186 | ```csv 187 | id,name,mail,org,scopes,twitter,github,oidc 188 | user1,test user,user1@example.com,R&D,"admin,user,org:rd",user1,user1,user1@example.com 189 | ``` 190 | 191 | ### Backend Server Configuration 192 | 193 | - `WRU_FORWARD_TO`: Specify you backend server (required) 194 | - `WRU_SERVER_SESSION_FIELD`: Header field name that WRU adds to backend request (default is "Wru-Session") 195 | 196 | #### Frontend User Experience Configuration 197 | 198 | - `WRU_DEFAULT_LANDING_PAGE`: WRU tries to redirect to referrer page after login. It is used when the path is not available (default is '/'). 199 | - `WRU_LOGIN_TIMEOUT_TERM`: Login session token's expiration term (default is '10m') 200 | - `WRU_SESSION_IDLE_TIMEOUT_TERM`: Active session token's timeout term (default is '1h') 201 | - `WRU_SESSION_ABSOLUTE_TIMEOUT_TERM`: Absolute session token's timeout term (default is '720h') 202 | - `WRU_HTML_TEMPLATE_FOLDER`: Login/User pages' template (default tempalte is embedded ones) 203 | 204 | #### ID Provider Configuration 205 | 206 | To enable ID provider connection, set the following env vars. 207 | The callback address will be `${HOST}/.wru/callback`. You should register the URL in the setting screen of the ID provider. 208 | 209 | ##### Twitter 210 | 211 | - `WRU_TWITTER_CONSUMER_KEY` 212 | - `WRU_TWITTER_CONSUMER_SECRET` 213 | 214 | ##### GitHub 215 | 216 | - `WRU_GITHUB_CLIENT_ID` 217 | - `WRU_GITHUB_CLIENT_SECRET` 218 | 219 | ##### OpenID Connect 220 | 221 | - `WRU_OIDC_PROVIDER_URL` 222 | - `WRU_OIDC_CLIENT_ID` 223 | - `WRU_OIDC_CLIENT_SECRET` 224 | 225 | #### Extra Option 226 | 227 | - `WRU_GEIIP_DATABASE`: GeoIP2 or GeoLite2 file (.mmdb) to detect user location from IP address 228 | 229 | ## Use as Middleware 230 | 231 | wru can work as middleware of HTTP service. Sample is in `cmd/sampleapp`. 232 | 233 | `NewAuthorizationMiddleware()` returns required HTTP handler (that includes, login form, callback for OAuth2 and so on) and middleware. 234 | Don't apply the middleware to the wru's handler (it causes infinity loop). 235 | 236 | You can create `*wru.Config` by using the structure directly or `wru.NewConfigFromEnv()`. 237 | 238 | ```go 239 | package main 240 | 241 | import ( 242 | "context" 243 | "fmt" 244 | "net/http" 245 | "os" 246 | "os/signal" 247 | 248 | "github.com/go-chi/chi/v5" 249 | "github.com/go-chi/chi/v5/middleware" 250 | "gitlab.com/osaki-lab/wru" 251 | 252 | // Select backend of session storage (docstore) and identity register (blob) 253 | _ "gocloud.dev/docstore/memdocstore" 254 | ) 255 | 256 | func main() { 257 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 258 | defer stop() 259 | 260 | r := chi.NewRouter() 261 | c := &wru.Config{ 262 | Port: 3000, 263 | Host: "http://localhost:3000", 264 | DevMode: true, 265 | Users: []*wru.User{ 266 | { 267 | DisplayName: "Test User 1", 268 | Organization: "Secret", 269 | UserID: "testuser01", 270 | Email: "testuser01@example.com", 271 | }, 272 | }, 273 | } 274 | wruHandler, authMiddleware := wru.NewAuthorizationMiddleware(ctx, c, os.Stdout) 275 | r.Use(middleware.Logger) 276 | r.Mount("/", wruHandler) 277 | r.With(authMiddleware).Get("/", func(w http.ResponseWriter, r *http.Request) { 278 | _, session := wru.GetSession(r) 279 | w.Write([]byte("welcome " + session.UserID)) 280 | }) 281 | http.ListenAndServe(fmt.Sprintf(":%d", c.Port), r) 282 | } 283 | ``` 284 | 285 | ## License 286 | 287 | Apache 2 288 | -------------------------------------------------------------------------------- /README_ja.md: -------------------------------------------------------------------------------- 1 | # WRU: who are you 2 | 3 | ![wru logo](doc/logo-small.png) 4 | 5 | WRU はエンタープライズ向けの Identity-aware リバースプロキシ/ミドルウェアです。 6 | WRU は開発環境と本番環境でシームレス使える認可機構を提供します。 7 | 8 | ## WRU は何向けか? 9 | 10 | - 2B なユーザー向け 11 | 12 | - ユーザー情報はストレージ上の CSV ファイル(ローカル、AWS nS3、GCP Cloud Storage)から読み込み可能 13 | 14 | - 2C ユーザー向けではない 15 | 16 | - 動的なユーザー登録機構は未実装 17 | 18 | - テスト環境向け 19 | 20 | - ユーザー情報はファイル以外にも環境変数で追加可能なので Docker でも簡単に初期化可能 21 | - テスト環境ではパスワード不要でのログインが可能(E2E テストがかんたん) 22 | 23 | - 本番環境向け 24 | - OpenID Connect や、いくつかの SNS(現在は Twitter と GitHub)での認証をサポート 25 | 26 | ## リバースプロキシとして利用 27 | 28 | ### 導入方法 29 | 30 | "go get"で wru のインストールが可能です: 31 | 32 | ```bash 33 | $ go get -u github.com/future-architect/future-wru/cmd/wru 34 | $ wru 35 | Port: 8000 36 | TLS: enabled 37 | Debug: false 38 | Forward To: 39 | / => http://localhost:8080 () 40 | Twitter Login: OK 41 | GitHub Login: OK 42 | Users (for Debug): 43 | (User) 'test user 1'(user1) @ R&D (scopes: admin, user, org:rd) 44 | (User) 'test user 2'(user2) @ HR (scopes: user, org:hr) 45 | starting wru server at https://localhost:8000 46 | ``` 47 | 48 | `wru` コマンドはコマンドラインオプションをサポートしていません。動作のカスタマイズは環境変数を使って行います。 49 | 50 | ### WRU の実行モード 51 | 52 | WRU は 2 つの実行モードを持ちます。バックエンドのサービス側はモードの違いを意識する必要はなく、常に必要な認証が行われる前提でアプリケーションを実装できます。 53 | 54 | #### ローカル開発向けモード 55 | 56 | ![dev-mode](doc/dialogs-dev-mode.png) 57 | 58 | サンプルの設定: 59 | 60 | - https://localhost:8000 で起動 61 | - wru が TLS をサポート 62 | - バックエンドサーバーは http://localhost:8080 で稼働 63 | - テストユーザーは人 64 | - Session storage is in-memory mode (turn off wru resets data) 65 | 66 | ```bash 67 | $ export WRU_DEV_MODE=true 68 | $ export WRU_TLS_CERT="-----BEGIN CERTIFICATE-----\naaaabbbbbcccccdddd....zzzz\n-----END CERTIFICATE-----" 69 | $ export WRU_TLS_KEY="-----BEGIN PRIVATE KEY-----\nZZZZYYYYYYXXXX.....BBBBAAAA\n-----END PRIVATE KEY-----" 70 | $ export WRU_FORWARD_TO="/ => http://localhost:8080" 71 | $ export WRU_USER_1="id:user1,name:test user 1,mail:user1@example.com,org:R&D,scope:admin,scope:user,scope:org:rd,twitter:user1,github:user1" 72 | $ export WRU_USER_2="id:user2,name:test user 2,mail:user2@example.com,org:HR,scope:user,scope:org:hr,twitter:user2,github:user2" 73 | $ PORT=8000 HOST=https://localhost:8000 wru 74 | ``` 75 | 76 | #### プロダクションモード 77 | 78 | ![production-mode](doc/dialogs-production-mode.png) 79 | 80 | サンプルの設定: 81 | 82 | - example.com で起動(ローカルのポートは 8000) 83 | - wru では TLS 提供はなし (AWS ALB などが提供) 84 | - バックエンドサーバーはhttp://server.example.comで稼働 85 | - ユーザー情報は S3 から読み込み(1 時間ごとにリロード) 86 | - セッションストレージは DynamoDB 87 | - Twitter/GitHub/OpenID Connect ログインを利用 88 | 89 | ```bash 90 | $ export WRU_DEV_MODE=false 91 | $ export WRU_FORWARD_TO="/ => http://server.example.com" 92 | $ export WRU_USER_TABLE="s3://my-app-usertable/user-list.csv?region=us-west-1" 93 | $ export WRU_USER_TABLE_RELOAD_TERM=1h 94 | $ export WRU_TWITTER_CONSUMER_KEY=1111111 95 | $ export WRU_TWITTER_CONSUMER_SECRET=22222222 96 | $ export WRU_GITHUB_CLIENT_ID=33333333 97 | $ export WRU_GITHUB_CLIENT_SECRET=44444444 98 | $ export WRU_OIDC_PROVIDER_URL=http://keycloak.example.com 99 | $ export WRU_OIDC_CLIENT_ID=55555555 100 | $ export WRU_OIDC_CLIENT_SECRET=66666666 101 | $ PORT=8000 HOST=https://example.com wru 102 | ``` 103 | 104 | ### フロントエンド向けのエンドポイント 105 | 106 | - `/.wru/login`: ログインページ 107 | - `/.wru/logout`: ログアウトページ(GET アクセスでログアウト実行) 108 | - `/.wru/user`: ユーザーページ(HTML/JSON 形式をサポート) 109 | - `/.wru/user/sessions`: ユーザーのログインセッション情報ページ(HTML/JSON 形式をサポート) 110 | 111 | ### セッションストレージ 112 | 113 | ブラウザのクッキーと似た、セッションストレージ機構を提供しています。 114 | 115 | ![session storage](doc/dialogs-session-storage.png) 116 | 117 | ウェブアプリケーションではクッキーのように`Wru-Set-Session-Data`ヘッダーフィールドにセッションストレージに入れたいデータを入れてレスポンスとして送ります: 118 | 119 | ```http 120 | Wru-Set-Session-Data: access-count=10 121 | ``` 122 | 123 | wru がこのヘッダーフィールドをフィルターして(ブラウザ側に送られることはありません)、その内容をセッションストレージに格納します。 124 | 125 | 格納した内容は、次回のリクエスト時に`Wru-Session`ヘッダーフィールド(`WRU_SERVER_SESSION_FIELD`環境変数で変更可能)に入れられてバックエンドサーバーに送られます。 126 | 127 | ```go 128 | Wru-Session: {"login_at":1212121,"id":"shibu","name":"Yoshiki Shibukawa","scopes":["user","admin"],data:{"access-count":"10"}} 129 | ``` 130 | 131 | このすべての内容を Go で読むには、次の構造体を使ってパースします: 132 | 133 | ```go 134 | type Session struct { 135 | LoginAt int64 `json:"login_at"` // ナノ秒 136 | ExpireAt int64 `json:"expire_at"` // ナノ秒 137 | LastAccessAt int64 `json:"last_access_at"` // ナノ秒 138 | UserID string `json:"id"` 139 | DisplayName string `json:"name"` 140 | Email string `json:"email"` 141 | Organization string `json:"org"` 142 | Scopes []string `json:"scopes"` 143 | Data map[string]string `json:"data"` 144 | } 145 | 146 | func ParseSession(r *http.Request) (*Session, error) { 147 | h := r.Header.Get("Wru-Session") 148 | if h != "" { 149 | var s Session 150 | err := json.NewDecoder(strings.NewReader(h)).Decode(&s) 151 | if err != nil { 152 | return nil, err 153 | } 154 | return &s, nil 155 | } 156 | return nil, err 157 | } 158 | ``` 159 | 160 | ### 設定 161 | 162 | #### wru のプロセスの関連の設定 163 | 164 | - `PORT`: wru が利用するポート番号(デフォルトは 3000) 165 | - `HOST`: wru が外部から利用可能なホスト名(必須)。OAuth/OpenID Connect のコールバック先としても利用される。 166 | - `WRU_DEV_MODE`: 実行モードの変更(次節で説明) 167 | - `WRU_TLS_CERT` と `WRU_TLS_KEY`: TLS のサーバーを起動 168 | 169 | ### ストレージ設定 170 | 171 | WRU はユーザー情報はオンメモリで持ちます。ユーザー情報は、CSV ファイルや環境変数から読み込みます。 172 | 173 | - `WRU_SESSION_STORAGE`: セッションストレージ。デフォルトはメモリ。DynamoDB、Firestore、MongoDB もサポート 174 | - `WRU_USER_TABLE`: CSV ファイルを読み込むローカルファイル/Blob(AWS S3、GCP Cloud Storage)のパス。 175 | - `WRU_USER_TABLE_RELOAD_TERM`: ファイルリロード間隔 176 | - `WRU_USER_%d`: 環境変数経由でユーザー追加(テスト用) 177 | 178 | ユーザーを環境変数で追加する場合、カンマ区切りのタグ付きの値リストで情報を設定します: 179 | 180 | ```bash 181 | WRU_USER_1="id:user1,name:test user 1,mail:user1@example.com,org:R&D,scope:admin,scope:user,scope:org:rd,twitter:user1" 182 | ``` 183 | 184 | ユーザー情報 CSV ファイルは特定のキーのヘッダー行を付与します(順序は変更可能)。 185 | 186 | ```csv 187 | id,name,mail,org,scopes,twitter,github,oidc 188 | user1,test user,user1@example.com,R&D,"admin,user,org:rd",user1,user1,user1@example.com 189 | ``` 190 | 191 | ### バックエンドサーバー関連の設定 192 | 193 | - `WRU_FORWARD_TO`: バックエンドサーバーを指定(必須) 194 | - `WRU_SERVER_SESSION_FIELD`: バックエンドサーバー向けのリクエストに付与する、セッション情報のヘッダーフィールド名(デフォルトは "Wru-Session") 195 | 196 | #### フロントエンドのユーザー体験に関する設定 197 | 198 | - `WRU_DEFAULT_LANDING_PAGE`: WRU はなるべく初回アクセスのあったページにログイン後に復帰させようとします。この変数はその情報が得られなかった時のデフォルトのパスです(デフォルトは'/') 199 | - `WRU_LOGIN_TIMEOUT_TERM`: ログイン前のセッショントークンが期限切れになる期間(デフォルトは'10m') 200 | - `WRU_SESSION_IDLE_TIMEOUT_TERM`: アクティブなセッショントークンがタイムアウトする期間(デフォルトは'1h') 201 | - `WRU_SESSION_ABSOLUTE_TIMEOUT_TERM`: セッションが最終的にタイムアウトになる期間(デフォルトは'720h') 202 | - `WRU_HTML_TEMPLATE_FOLDER`: ログインやユーザーページのテンプレート(デフォルトは内蔵テンプレートを利用) 203 | 204 | #### ID プロバイダの設定 205 | 206 | ID プロバイダを追加するには次の環境変数の設定が必要です。 207 | コールバックアドレスはどのプロバイダーでも`${HOST}/.wru/callback`となります。 208 | ID プロバイダ側に wru を RP として登録する場合は、この URL を設定してください。 209 | 210 | ##### Twitter 211 | 212 | - `WRU_TWITTER_CONSUMER_KEY` 213 | - `WRU_TWITTER_CONSUMER_SECRET` 214 | 215 | ##### GitHub 216 | 217 | - `WRU_GITHUB_CLIENT_ID` 218 | - `WRU_GITHUB_CLIENT_SECRET` 219 | 220 | ##### OpenID Connect 221 | 222 | - `WRU_OIDC_PROVIDER_URL` 223 | - `WRU_OIDC_CLIENT_ID` 224 | - `WRU_OIDC_CLIENT_SECRET` 225 | 226 | #### 追加オプション 227 | 228 | - `WRU_GEIIP_DATABASE`: GeoIP2/GeoLite2 のファイル(.mmdb)。ユーザーの所在地を IP アドレスから推測するのに利用。 229 | 230 | ## ミドルウェアとしての利用 231 | 232 | wru は HTTP サービスのミドルウェアとしても動作します。サンプルは`cmd/sampleapp`を参照してください。 233 | 234 | `NewAuthorizationMiddleware()`関数が、動作に必要な HTTP ハンドラ(ログインフォーム、OAuth のコールバックなどを含む)とミドルウェアを返します。 235 | ミドルウェアを、wru 自身のハンドラには適用しないようにしてください(無限ループとなります)。 236 | 237 | `*wru.Config`は次のサンプルの作成方法(構造体を直接利用)のほか、`wru.NewConfigFromEnv()`でも作成できます。 238 | 239 | ```go 240 | package main 241 | 242 | import ( 243 | "context" 244 | "fmt" 245 | "net/http" 246 | "os" 247 | "os/signal" 248 | 249 | "github.com/go-chi/chi/v5" 250 | "github.com/go-chi/chi/v5/middleware" 251 | "gitlab.com/osaki-lab/wru" 252 | 253 | // Select backend of session storage (docstore) and identity register (blob) 254 | _ "gocloud.dev/docstore/memdocstore" 255 | ) 256 | 257 | func main() { 258 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 259 | defer stop() 260 | 261 | r := chi.NewRouter() 262 | c := &wru.Config{ 263 | Port: 3000, 264 | Host: "http://localhost:3000", 265 | DevMode: true, 266 | Users: []*wru.User{ 267 | { 268 | DisplayName: "Test User 1", 269 | Organization: "Secret", 270 | UserID: "testuser01", 271 | Email: "testuser01@example.com", 272 | }, 273 | }, 274 | } 275 | wruHandler, authMiddleware := wru.NewAuthorizationMiddleware(ctx, c, os.Stdout) 276 | r.Use(middleware.Logger) 277 | r.Mount("/", wruHandler) 278 | r.With(authMiddleware).Get("/", func(w http.ResponseWriter, r *http.Request) { 279 | _, session := wru.GetSession(r) 280 | w.Write([]byte("welcome " + session.UserID)) 281 | }) 282 | http.ListenAndServe(fmt.Sprintf(":%d", c.Port), r) 283 | } 284 | ``` 285 | 286 | ## ライセンス 287 | 288 | Apache 2 289 | -------------------------------------------------------------------------------- /adapter_github.go: -------------------------------------------------------------------------------- 1 | // warning: use OAuth for authentication is not secure 2 | 3 | package wru 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "strings" 12 | 13 | "github.com/google/go-github/github" 14 | "github.com/gookit/color" 15 | "github.com/shibukawa/uuid62" 16 | "golang.org/x/oauth2" 17 | githubEndpoint "golang.org/x/oauth2/github" 18 | ) 19 | 20 | var githubClient *oauth2.Config 21 | 22 | func initGitHubConfig(c *Config, out io.Writer) { 23 | if c.GitHub.Available() { 24 | callback := strings.TrimSuffix(c.Host, "/") + "/.wru/callback" 25 | githubClient = &oauth2.Config{ 26 | ClientID: c.GitHub.ClientID, 27 | ClientSecret: c.GitHub.ClientSecret, 28 | Endpoint: githubEndpoint.Endpoint, 29 | Scopes: []string{"user:email"}, 30 | RedirectURL: callback, 31 | } 32 | c.availableIDPs["github"] = true 33 | if out != nil { 34 | color.Fprint(out, "GitHub Login: OK\n") 35 | } 36 | } else if out != nil { 37 | color.Fprint(out, "GitHub Login: NO\n") 38 | } 39 | } 40 | 41 | func gitHubLoginStart(c *Config) (redirectUrl string, loginInfo map[string]string, err error) { 42 | state, err := uuid62.V4() 43 | if err != nil { 44 | return "", nil, err 45 | } 46 | redirectUrl = githubClient.AuthCodeURL(state) 47 | loginInfo = map[string]string{ 48 | "idp": "github", 49 | "state": state, 50 | } 51 | return 52 | } 53 | 54 | func githubCallback(c *Config, r *http.Request, loginInfo map[string]string) (gitHubID string, newLoginInfo map[string]string, err error) { 55 | if err := r.ParseForm(); err != nil { 56 | return "", nil, fmt.Errorf("parse form error: %w", err) 57 | } 58 | 59 | if loginInfo["state"] != r.Form.Get("state") { 60 | err = errors.New("state is different") 61 | return 62 | } 63 | 64 | token, err := githubClient.Exchange(context.Background(), r.Form.Get("code")) 65 | if err != nil { 66 | err = fmt.Errorf("can't get access token: %w", err) 67 | return 68 | } 69 | 70 | tokenSource := oauth2.StaticTokenSource( 71 | &oauth2.Token{AccessToken: token.AccessToken}, 72 | ) 73 | 74 | client := github.NewClient(oauth2.NewClient(context.Background(), tokenSource)) 75 | 76 | user, _, err := client.Users.Get(context.Background(), "") 77 | if err != nil { 78 | err = fmt.Errorf("can't get access token: %w", err) 79 | return 80 | } 81 | 82 | gitHubID = user.GetLogin() 83 | newLoginInfo = map[string]string{ 84 | "login-idp": "github", 85 | // "github-refresh": token.RefreshToken, 86 | // "github-token": token.AccessToken, 87 | } 88 | return 89 | } 90 | -------------------------------------------------------------------------------- /adapter_oidc.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/coreos/go-oidc" 8 | "github.com/gookit/color" 9 | "github.com/shibukawa/uuid62" 10 | "golang.org/x/oauth2" 11 | "io" 12 | "net/http" 13 | "strings" 14 | ) 15 | 16 | var provider *oidc.Provider 17 | var oauth2Config *oauth2.Config 18 | 19 | func initOpenIDConnectConfig(ctx context.Context, c *Config, out io.Writer) error { 20 | if c.OIDC.Available() { 21 | var err error 22 | // ここにissuer情報を設定 23 | provider, err = oidc.NewProvider(ctx, c.OIDC.ProviderURL) 24 | if err != nil { 25 | return err 26 | } 27 | callback := strings.TrimSuffix(c.Host, "/") + "/.wru/callback" 28 | oauth2Config = &oauth2.Config{ 29 | // ここにクライアントIDとクライアントシークレットを設定 30 | ClientID: c.OIDC.ClientID, 31 | ClientSecret: c.OIDC.ClientSecret, 32 | Endpoint: provider.Endpoint(), 33 | Scopes: []string{oidc.ScopeOpenID}, 34 | RedirectURL: callback, 35 | } 36 | if out != nil { 37 | color.Fprint(out, "OpenID Connect Login: OK\n") 38 | } 39 | } else if out != nil { 40 | color.Fprint(out, "OpenID Connect Login: NO\n") 41 | } 42 | return nil 43 | } 44 | 45 | func oidcLoginStart(c *Config) (redirectUrl string, loginInfo map[string]string, err error) { 46 | state, err := uuid62.V4() 47 | if err != nil { 48 | return "", nil, err 49 | } 50 | redirectUrl = oauth2Config.AuthCodeURL(state) 51 | loginInfo = map[string]string{ 52 | "idp": "oidc", 53 | "state": state, 54 | } 55 | return 56 | } 57 | 58 | func oidcCallback(c *Config, r *http.Request, loginInfo map[string]string) (oidcID string, newLoginInfo map[string]string, err error) { 59 | if err := r.ParseForm(); err != nil { 60 | return "", nil, fmt.Errorf("parse form error: %w", err) 61 | } 62 | 63 | if loginInfo["state"] != r.Form.Get("state") { 64 | err = errors.New("state is different") 65 | return 66 | } 67 | 68 | nonce, err := uuid62.V4() 69 | if err != nil { 70 | return "", nil, err 71 | } 72 | 73 | accessToken, err := oauth2Config.Exchange(context.Background(), r.Form.Get("code"), oidc.Nonce(nonce)) 74 | if err != nil { 75 | err = fmt.Errorf("can't get access token: %w", err) 76 | return 77 | } 78 | 79 | rawIDToken, ok := accessToken.Extra("id_token").(string) 80 | if !ok { 81 | err = fmt.Errorf("id token missing") 82 | return 83 | } 84 | 85 | oidcConfig := &oidc.Config{ 86 | ClientID: c.OIDC.ClientID, 87 | } 88 | verifier := provider.Verifier(oidcConfig) 89 | idToken, err := verifier.Verify(r.Context(), rawIDToken) 90 | if err != nil { 91 | err = fmt.Errorf("id token verify error: %v", err) 92 | return 93 | } 94 | // IDトークンのクレームをとりあえずダンプ 95 | // アプリで必要なものはセッションストレージに入れておくと良いでしょう 96 | idTokenClaims := map[string]interface{}{} 97 | if err := idToken.Claims(&idTokenClaims); err != nil { 98 | return "", nil, fmt.Errorf("getting claims from id token error: %v", err) 99 | } 100 | aud, ok := idTokenClaims["aud"].(string) 101 | if !ok || aud != c.OIDC.ClientID { 102 | return "", nil, fmt.Errorf("this code is not for this service: %s", aud) 103 | } 104 | if nonce2, ok := idTokenClaims["nonce"].(string); ok { 105 | if nonce2 != nonce { 106 | return "", nil, fmt.Errorf("exchange id token error") 107 | } 108 | } 109 | oidcID, ok = idTokenClaims["email"].(string) 110 | if !ok { 111 | oidcID = idTokenClaims["sub"].(string) 112 | } 113 | newLoginInfo = map[string]string{ 114 | "login-idp": "oidc", 115 | // "github-refresh": token.RefreshToken, 116 | // "github-token": token.AccessToken, 117 | } 118 | return 119 | } 120 | -------------------------------------------------------------------------------- /adapter_twitter.go: -------------------------------------------------------------------------------- 1 | // warning: use OAuth for authentication is not secure 2 | 3 | package wru 4 | 5 | import ( 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "github.com/gookit/color" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "strings" 14 | 15 | "github.com/garyburd/go-oauth/oauth" 16 | ) 17 | 18 | var twitterClient *oauth.Client 19 | 20 | func initTwitterClient(c *Config, out io.Writer) { 21 | if c.Twitter.Available() { 22 | twitterClient = &oauth.Client{ 23 | TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token", 24 | ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authorize", 25 | TokenRequestURI: "https://api.twitter.com/oauth/access_token", 26 | Credentials: oauth.Credentials{ 27 | Token: c.Twitter.ConsumerKey, 28 | Secret: c.Twitter.ConsumerSecret, 29 | }, 30 | } 31 | c.availableIDPs["twitter"] = true 32 | if out != nil { 33 | color.Fprint(out, "Twitter Login: OK\n") 34 | } 35 | } else if out != nil { 36 | color.Fprint(out, "Twitter Login: NO\n") 37 | } 38 | } 39 | 40 | type twitterAccount struct { 41 | ID string `json:"id_str"` 42 | ScreenName string `json:"screen_name"` 43 | ProfileImageURL string `json:"profile_image_url"` 44 | Email string `json:"email"` 45 | } 46 | 47 | func twitterLoginStart(c *Config) (redirectUrl string, loginInfo map[string]string, err error) { 48 | callback := strings.TrimSuffix(c.Host, "/") + "/.wru/callback" 49 | tc, err := twitterClient.RequestTemporaryCredentials(nil, callback, nil) 50 | if err != nil { 51 | err = fmt.Errorf("Twitter login error at getting temporary credential: %w", err) 52 | return 53 | } 54 | redirectUrl = twitterClient.AuthorizationURL(tc, nil) 55 | loginInfo = map[string]string{ 56 | "idp": "twitter", 57 | "token-key": tc.Token, 58 | "token-secret": tc.Secret, 59 | } 60 | return redirectUrl, loginInfo, nil 61 | } 62 | 63 | func twitterCallback(c *Config, r *http.Request, loginInfo map[string]string) (twitterID string, newLoginInfo map[string]string, err error) { 64 | if err := r.ParseForm(); err != nil { 65 | return "", nil, fmt.Errorf("parse form error: %w", err) 66 | } 67 | 68 | tokenKey, ok1 := loginInfo["token-key"] 69 | secretKey, ok2 := loginInfo["token-secret"] 70 | if !ok1 || !ok2 { 71 | return "", nil, errors.New("internal server error: loginInfo is broken") 72 | } 73 | tc := &oauth.Credentials{ 74 | Token: tokenKey, 75 | Secret: secretKey, 76 | } 77 | 78 | if tc.Token != r.FormValue("oauth_token") { 79 | return "", nil, errors.New("internal server error: oauth_token missing") 80 | } 81 | 82 | cred, _, err := twitterClient.RequestToken(nil, tc, r.FormValue("oauth_verifier")) 83 | if err != nil { 84 | return "", nil, fmt.Errorf("getting access token error: %w", err) 85 | } 86 | 87 | resp, err := twitterClient.Get(nil, cred, "https://api.twitter.com/1.1/account/verify_credentials.json", url.Values{}) 88 | 89 | if err != nil { 90 | return "", nil, fmt.Errorf("twitter login error error: %w", err) 91 | } 92 | defer resp.Body.Close() 93 | 94 | if resp.StatusCode >= 500 { 95 | return "", nil, fmt.Errorf("twitter is unavailable: %w", err) 96 | } 97 | 98 | if resp.StatusCode >= 400 { 99 | return "", nil, fmt.Errorf("internal server error: invalid request for twitter: %w", err) 100 | } 101 | 102 | var user twitterAccount 103 | err = json.NewDecoder(resp.Body).Decode(&user) 104 | if err != nil { 105 | return "", nil, fmt.Errorf("internal server error: json decode error: %w", err) 106 | } 107 | twitterID = user.ScreenName 108 | newLoginInfo = map[string]string{ 109 | "login-idp": "twitter", 110 | // "twitter-secret": cred.Secret, 111 | // "twitter-token": cred.Token, 112 | } 113 | return 114 | } 115 | -------------------------------------------------------------------------------- /cmd/sampleapp/.gitignore: -------------------------------------------------------------------------------- 1 | sampleapp 2 | sampleapp.exe 3 | -------------------------------------------------------------------------------- /cmd/sampleapp/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "sync" 11 | "time" 12 | 13 | wru "github.com/future-architect/future-wru" 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | 17 | // Select backend of session storage (docstore) and identity register (blob) 18 | _ "gocloud.dev/docstore/memdocstore" 19 | ) 20 | 21 | func init() { 22 | log.SetFlags(log.LstdFlags | log.Lshortfile) 23 | } 24 | 25 | func main() { 26 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 27 | defer stop() 28 | 29 | r := chi.NewRouter() 30 | c := &wru.Config{ 31 | Port: 3000, 32 | Host: "http://localhost:3000", 33 | DevMode: true, 34 | Users: []*wru.User{ 35 | { 36 | DisplayName: "Test User 1", 37 | Organization: "Secret", 38 | UserID: "testuser01", 39 | Email: "testuser01@example.com", 40 | }, 41 | { 42 | DisplayName: "Test User 2", 43 | Organization: "Secret", 44 | UserID: "testuser02", 45 | Email: "testuser02@example.com", 46 | }, 47 | }, 48 | } 49 | wruHandler, authMiddleware := wru.NewAuthorizationMiddleware(ctx, c, os.Stdout) 50 | r.Use(middleware.Logger) 51 | r.Mount("/", wruHandler) 52 | r.With(authMiddleware).Get("/", func(w http.ResponseWriter, r *http.Request) { 53 | _, session := wru.GetSession(r) 54 | w.Write([]byte("welcome " + session.UserID)) 55 | }) 56 | srv := &http.Server{ 57 | Addr: fmt.Sprintf(":%d", c.Port), 58 | Handler: r, 59 | } 60 | 61 | var wg sync.WaitGroup 62 | wg.Add(1) 63 | go func() { 64 | defer wg.Done() 65 | fmt.Printf("starting wru server at http://localhost:%d\n", c.Port) 66 | err := srv.ListenAndServe() 67 | if err != http.ErrServerClosed { 68 | // unexpected error. port in use? 69 | fmt.Fprintf(os.Stderr, "Server Error: %v", err) 70 | os.Exit(1) 71 | } 72 | }() 73 | <-ctx.Done() 74 | wait, cancel := context.WithTimeout(context.Background(), 10*time.Second) 75 | defer cancel() 76 | if err := srv.Shutdown(wait); err != nil { 77 | fmt.Fprintf(os.Stderr, "Shutdown Server Error: %v", err) 78 | os.Exit(1) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /cmd/wru/.gitignore: -------------------------------------------------------------------------------- 1 | wru 2 | wru.exe 3 | -------------------------------------------------------------------------------- /cmd/wru/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "sync" 12 | "time" 13 | 14 | wru "github.com/future-architect/future-wru" 15 | "github.com/gookit/color" 16 | 17 | _ "gocloud.dev/docstore/awsdynamodb" 18 | _ "gocloud.dev/docstore/gcpfirestore" 19 | _ "gocloud.dev/docstore/memdocstore" 20 | _ "gocloud.dev/docstore/mongodocstore" 21 | 22 | _ "gocloud.dev/blob/fileblob" 23 | _ "gocloud.dev/blob/gcsblob" 24 | _ "gocloud.dev/blob/s3blob" 25 | ) 26 | 27 | func init() { 28 | log.SetFlags(log.LstdFlags | log.Lshortfile) 29 | log.SetPrefix("🦀 ") 30 | } 31 | 32 | func main() { 33 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 34 | defer stop() 35 | 36 | c, err := wru.NewConfigFromEnv(ctx, os.Stdout) 37 | if err != nil { 38 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Parse config error: %s", err.Error())) 39 | os.Exit(1) 40 | } 41 | sessionStorage, err := wru.NewSessionStorage(ctx, c, os.Stdout) 42 | if err != nil { 43 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Connect session error: %s", err.Error())) 44 | os.Exit(1) 45 | } 46 | userStorage, warnings, err := wru.NewIdentityRegister(ctx, c, os.Stdout) 47 | if err != nil { 48 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Read user table error: %s", err.Error())) 49 | os.Exit(1) 50 | } 51 | for _, w := range warnings { 52 | fmt.Fprintln(os.Stderr, color.Warn.Sprintf("User parse warning: %s", w)) 53 | } 54 | handler, err := wru.NewIdentityAwareProxyHandler(c, sessionStorage, userStorage) 55 | 56 | srv := &http.Server{ 57 | Addr: fmt.Sprintf(":%d", c.Port), 58 | Handler: handler, 59 | } 60 | 61 | var cert tls.Certificate 62 | if c.TlsCert != "" && c.TlsKey != "" { 63 | cert, err = tls.X509KeyPair([]byte(c.TlsCert), []byte(c.TlsKey)) 64 | if err != nil { 65 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("TLS error: %s", err.Error())) 66 | return 67 | } 68 | } 69 | var wg sync.WaitGroup 70 | wg.Add(1) 71 | go func() { 72 | defer wg.Done() 73 | var err error 74 | if c.TlsCert != "" && c.TlsKey != "" { 75 | color.Infof("starting wru server at https://localhost:%d\n", c.Port) 76 | srv.TLSConfig = &tls.Config{ 77 | Certificates: []tls.Certificate{cert}, 78 | } 79 | err = srv.ListenAndServeTLS("", "") 80 | } else { 81 | color.Infof("starting wru server at http://localhost:%d\n", c.Port) 82 | err = srv.ListenAndServe() 83 | } 84 | if err != http.ErrServerClosed { 85 | // unexpected error. port in use? 86 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Server Error: %v", err)) 87 | os.Exit(1) 88 | } 89 | }() 90 | <-ctx.Done() 91 | wait, cancel := context.WithTimeout(context.Background(), 10*time.Second) 92 | defer cancel() 93 | if err := srv.Shutdown(wait); err != nil { 94 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Shutdown Server Error: %v", err)) 95 | os.Exit(1) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/gookit/color" 8 | "io" 9 | "net/url" 10 | "os" 11 | "regexp" 12 | "strings" 13 | "time" 14 | 15 | "github.com/kelseyhightower/envconfig" 16 | "github.com/oschwald/geoip2-golang" 17 | ) 18 | 19 | type ClientSessionFieldType int 20 | 21 | const ( 22 | CookieField ClientSessionFieldType = iota + 1 23 | CookieWithJSField 24 | InvalidField 25 | ) 26 | 27 | type configFromEnv struct { 28 | Port uint16 `envconfig:"PORT" default:"3000"` 29 | Host string `envconfig:"HOST" required:"true"` 30 | AdminPort uint16 `envconfig:"ADMIN_PORT" default:"3001"` // todo: implment admin screen 31 | 32 | DevMode bool `envconfig:"WRU_DEV_MODE" default:"true"` 33 | TlsCert string `envconfig:"WRU_TLS_CERT"` 34 | TlsKey string `envconfig:"WRU_TLS_KEY"` 35 | ForwardTo string `envconfig:"WRU_FORWARD_TO" required:"true"` 36 | DefaultLandingPage string `envconfig:"WRU_DEFAULT_LANDING_PAGE" default:"/"` 37 | SessionStorage string `envconfig:"WRU_SESSION_STORAGE"` 38 | ClientSessionIDCookie string `envconfig:"WRU_CLIENT_SESSION_ID_COOKIE" default:"WRU_SESSION@cookie"` 39 | ServerSessionField string `envconfig:"WRU_SERVER_SESSION_FIELD" default:"Wru-Session"` 40 | 41 | UserTable string `envconfig:"WRU_USER_TABLE"` 42 | UserTableReloadTerm time.Duration `envconfig:"WRU_USER_TABLE_RELOAD_TERM"` 43 | 44 | LoginTimeoutTerm time.Duration `envconfig:"WRU_LOGIN_TIMEOUT_TERM" default:"10m"` 45 | SessionIdleTimeoutTerm time.Duration `envconfig:"WRU_SESSION_IDLE_TIMEOUT_TERM" default:"1h"` 46 | SessionAbsoluteTimeoutTerm time.Duration `envconfig:"WRU_SESSION_ABSOLUTE_TIMEOUT_TERM" default:"720h"` 47 | 48 | HTMLTemplateFolder string `envconfig:"WRU_HTML_TEMPLATE_FOLDER"` 49 | 50 | TwitterConsumerKey string `envconfig:"WRU_TWITTER_CONSUMER_KEY"` 51 | TwitterConsumerSecret string `envconfig:"WRU_TWITTER_CONSUMER_SECRET"` 52 | 53 | GitHubClientID string `envconfig:"WRU_GITHUB_CLIENT_ID"` 54 | GitHubClientSecret string `envconfig:"WRU_GITHUB_CLIENT_SECRET"` 55 | 56 | OIDCProviderURL string `envconfig:"WRU_OIDC_PROVIDER_URL"` 57 | OIDCClientID string `envconfig:"WRU_OIDC_CLIENT_ID"` 58 | OIDCClientSecret string `envconfig:"WRU_OIDC_CLIENT_SECRET"` 59 | 60 | GeoIPDatabase string `envconfig:"WRU_GEIIP_DATABASE"` 61 | } 62 | 63 | type Config struct { 64 | Port uint16 65 | Host string 66 | 67 | DevMode bool 68 | 69 | AdminPort uint16 70 | TlsCert string 71 | TlsKey string 72 | ForwardTo []Route 73 | DefaultLandingPage string 74 | UserTable string 75 | UserTableReloadTerm time.Duration 76 | SessionStorage string 77 | ServerSessionField string 78 | ClientSessionFieldCookie ClientSessionFieldType 79 | ClientSessionKey string 80 | 81 | LoginTimeoutTerm time.Duration 82 | SessionIdleTimeoutTerm time.Duration 83 | SessionAbsoluteTimeoutTerm time.Duration 84 | 85 | HTMLTemplateFolder string 86 | 87 | Twitter TwitterConfig 88 | GitHub GitHubConfig 89 | OIDC OIDCConfig 90 | 91 | availableIDPs map[string]bool 92 | 93 | RedisSession RedisConfig 94 | 95 | GeoIPDatabasePath string 96 | 97 | Users []*User 98 | 99 | // internal use 100 | geoIPDB *geoip2.Reader 101 | 102 | // internal use 103 | init bool 104 | } 105 | 106 | func NewConfigFromEnv(ctx context.Context, out io.Writer) (*Config, error) { 107 | var e configFromEnv 108 | err := envconfig.Process("", &e) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | routes, err := parseForwardList(e.ForwardTo) 114 | if err != nil { 115 | return nil, err 116 | } 117 | fieldKey, fieldType, err := parseClientSessionField(e.ClientSessionIDCookie) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | c := Config{ 123 | Port: e.Port, 124 | Host: e.Host, 125 | AdminPort: e.AdminPort, 126 | TlsCert: e.TlsCert, 127 | TlsKey: e.TlsKey, 128 | UserTable: e.UserTable, 129 | UserTableReloadTerm: e.UserTableReloadTerm, 130 | ForwardTo: routes, 131 | DefaultLandingPage: e.DefaultLandingPage, 132 | SessionStorage: e.SessionStorage, 133 | ServerSessionField: e.ServerSessionField, 134 | ClientSessionKey: fieldKey, 135 | ClientSessionFieldCookie: fieldType, 136 | LoginTimeoutTerm: e.LoginTimeoutTerm, 137 | SessionIdleTimeoutTerm: e.SessionIdleTimeoutTerm, 138 | SessionAbsoluteTimeoutTerm: e.SessionAbsoluteTimeoutTerm, 139 | HTMLTemplateFolder: e.HTMLTemplateFolder, 140 | Twitter: TwitterConfig{ 141 | ConsumerKey: e.TwitterConsumerKey, 142 | ConsumerSecret: e.TwitterConsumerSecret, 143 | }, 144 | GitHub: GitHubConfig{ 145 | ClientID: e.GitHubClientID, 146 | ClientSecret: e.GitHubClientSecret, 147 | }, 148 | OIDC: OIDCConfig{ 149 | ProviderURL: e.OIDCProviderURL, 150 | ClientID: e.OIDCClientID, 151 | ClientSecret: e.OIDCClientSecret, 152 | }, 153 | DevMode: e.DevMode, 154 | GeoIPDatabasePath: e.GeoIPDatabase, 155 | } 156 | err = c.Init(ctx, out) 157 | if err != nil { 158 | return nil, err 159 | } 160 | return &c, nil 161 | } 162 | 163 | func (c *Config) Init(ctx context.Context, out io.Writer) error { 164 | if c.init == true { 165 | return nil 166 | } 167 | 168 | // set defaults 169 | if c.Port == 0 { 170 | c.Port = 3000 171 | } 172 | if c.AdminPort == 0 { 173 | c.AdminPort = 3001 174 | } 175 | if c.DefaultLandingPage == "" { 176 | c.DefaultLandingPage = "/" 177 | } 178 | if c.ClientSessionKey == "" { 179 | c.ClientSessionKey = "WRU_SESSION" 180 | c.ClientSessionFieldCookie = CookieField 181 | } 182 | if c.ServerSessionField == "" { 183 | c.ServerSessionField = "Wru-Session" 184 | } 185 | if c.LoginTimeoutTerm == 0 { 186 | c.LoginTimeoutTerm = 10 * time.Minute 187 | } 188 | if c.SessionIdleTimeoutTerm == 0 { 189 | c.SessionIdleTimeoutTerm = 1 * time.Hour 190 | } 191 | if c.SessionAbsoluteTimeoutTerm == 0 { 192 | c.SessionAbsoluteTimeoutTerm = 720 * time.Hour 193 | } 194 | 195 | // existing check 196 | if c.Host == "" { 197 | return errors.New("config Host is required") 198 | } 199 | 200 | c.availableIDPs = make(map[string]bool) 201 | 202 | if !c.DevMode { 203 | initTwitterClient(c, out) 204 | initGitHubConfig(c, out) 205 | initOpenIDConnectConfig(ctx, c, out) 206 | if len(c.availableIDPs) == 0 { 207 | return errors.New("No ID Provider is available") 208 | } 209 | } 210 | 211 | if c.GeoIPDatabasePath != "" { 212 | db, err := geoip2.Open(c.GeoIPDatabasePath) 213 | if err != nil { 214 | return err 215 | } 216 | c.geoIPDB = db 217 | } 218 | err := initTemplate(c, os.Stdout) 219 | if err != nil { 220 | return fmt.Errorf("Parse HTML template error: %s", err.Error()) 221 | } 222 | 223 | c.init = true 224 | if out != nil { 225 | color.Fprintf(out, "Host: %s\n", c.Host) 226 | color.Fprintf(out, "Port: %d\n", c.Port) 227 | if c.TlsCert != "" && c.TlsKey != "" { 228 | color.Fprintf(out, "TLS: enabled\n") 229 | } else { 230 | color.Fprintf(out, "TLS: disabled\n") 231 | } 232 | color.Fprintf(out, "DevMode: %v\n", c.DevMode) 233 | color.Fprintf(out, "Forward To:\n") 234 | for _, r := range c.ForwardTo { 235 | color.Fprintf(out, " %s => %s (%s)\n", r.Path, r.Host.String(), strings.Join(r.Scopes, ", ")) 236 | } 237 | if c.GeoIPDatabasePath != "" { 238 | color.Fprintf(out, "GeoIP: enabled(%s)\n", c.GeoIPDatabasePath) 239 | } else { 240 | color.Fprintf(out, "GeoIP: disabled\n") 241 | } 242 | } 243 | return nil 244 | } 245 | 246 | var rre = regexp.MustCompile(`\s*(/.*)\s*=>\s*(https?://[^\s (]+)(\s*\((.*)\))?\s*`) 247 | 248 | func parseForwardList(src string) ([]Route, error) { 249 | var result []Route 250 | for i, route := range strings.Split(src, ";") { 251 | if strings.TrimSpace(route) == "" { 252 | continue 253 | } 254 | match := rre.FindStringSubmatch(route) 255 | if len(match) == 0 { 256 | return nil, fmt.Errorf("wrong route definition: (%d)=%s", i, route) 257 | } 258 | var scopes []string 259 | for _, s := range strings.Split(match[4], ",") { 260 | s = strings.TrimSpace(s) 261 | if s != "" { 262 | scopes = append(scopes, s) 263 | } 264 | } 265 | u, err := url.Parse(match[2]) 266 | if err != nil { 267 | return nil, err 268 | } 269 | result = append(result, Route{ 270 | Path: strings.TrimSpace(match[1]), 271 | Host: u, 272 | Scopes: scopes, 273 | }) 274 | } 275 | return result, nil 276 | } 277 | 278 | func parseClientSessionField(src string) (string, ClientSessionFieldType, error) { 279 | fragments := strings.SplitN(src, "@", 2) 280 | if len(fragments) == 1 { 281 | return src, CookieField, nil 282 | } 283 | if fragments[1] == "cookie" { 284 | return fragments[0], CookieField, nil 285 | } else if fragments[1] == "cookie-with-js" { 286 | return fragments[0], CookieWithJSField, nil 287 | } 288 | return "", InvalidField, errors.New("invalid client session field") 289 | } 290 | 291 | type Route struct { 292 | Path string 293 | Host *url.URL 294 | Scopes []string 295 | } 296 | 297 | type TwitterConfig struct { 298 | ConsumerKey string 299 | ConsumerSecret string 300 | } 301 | 302 | func (c TwitterConfig) Available() bool { 303 | return c.ConsumerKey != "" && c.ConsumerSecret != "" 304 | } 305 | 306 | type GitHubConfig struct { 307 | ClientID string 308 | ClientSecret string 309 | } 310 | 311 | func (c GitHubConfig) Available() bool { 312 | return c.ClientID != "" && c.ClientSecret != "" 313 | } 314 | 315 | type OIDCConfig struct { 316 | ProviderURL string 317 | ClientID string 318 | ClientSecret string 319 | } 320 | 321 | func (c OIDCConfig) Available() bool { 322 | return c.ProviderURL != "" && c.ClientID != "" && c.ClientSecret != "" 323 | } 324 | 325 | type RedisConfig struct { 326 | Host string 327 | } 328 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/url" 6 | "testing" 7 | ) 8 | 9 | func mustParseUrl(src string) *url.URL { 10 | u, err := url.Parse(src) 11 | if err != nil { 12 | panic(err) 13 | } 14 | return u 15 | } 16 | 17 | func Test_parseForwardList(t *testing.T) { 18 | type args struct { 19 | src string 20 | } 21 | tests := []struct { 22 | name string 23 | args args 24 | want []Route 25 | wantErr bool 26 | }{ 27 | { 28 | name: "single route with role", 29 | args: args{ 30 | src: "/api => http://localhost:8000 (admin, user)", 31 | }, 32 | want: []Route{ 33 | { 34 | Path: "/api", 35 | Host: mustParseUrl("http://localhost:8000"), 36 | Scopes: []string{"admin", "user"}, 37 | }, 38 | }, 39 | }, 40 | { 41 | name: "single route without role", 42 | args: args{ 43 | src: "/api => http://localhost:8000", 44 | }, 45 | want: []Route{ 46 | { 47 | Path: "/api", 48 | Host: mustParseUrl("http://localhost:8000"), 49 | Scopes: nil, 50 | }, 51 | }, 52 | }, 53 | { 54 | name: "wrong route without role", 55 | args: args{ 56 | src: "/api =>", 57 | }, 58 | wantErr: true, 59 | }, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | got, err := parseForwardList(tt.args.src) 64 | if tt.wantErr { 65 | assert.Error(t, err) 66 | } else { 67 | assert.NoError(t, err) 68 | assert.Equal(t, tt.want, got) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func Test_parseClientSessionHeader(t *testing.T) { 75 | type args struct { 76 | src string 77 | } 78 | tests := []struct { 79 | name string 80 | args args 81 | want string 82 | wantType ClientSessionFieldType 83 | wantErr bool 84 | }{ 85 | { 86 | name: "for header", 87 | args: args{ 88 | src: "SESSIONID", 89 | }, 90 | want: "SESSIONID", 91 | wantType: CookieField, 92 | }, 93 | { 94 | name: "for cookie", 95 | args: args{ 96 | src: "WruSession@cookie", 97 | }, 98 | want: "WruSession", 99 | wantType: CookieField, 100 | }, 101 | { 102 | name: "for cookie(include JS)", 103 | args: args{ 104 | src: "WruSession@cookie-with-js", 105 | }, 106 | want: "WruSession", 107 | wantType: CookieWithJSField, 108 | }, 109 | } 110 | for _, tt := range tests { 111 | t.Run(tt.name, func(t *testing.T) { 112 | got, gotType, err := parseClientSessionField(tt.args.src) 113 | if tt.wantErr { 114 | assert.Error(t, err) 115 | } else { 116 | assert.Equal(t, tt.want, got) 117 | assert.Equal(t, tt.wantType, gotType) 118 | assert.NoError(t, err) 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /doc/dialogs-dev-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/future-architect/future-wru/2e6671993bd66a397666ec355683edaefa8ab6d0/doc/dialogs-dev-mode.png -------------------------------------------------------------------------------- /doc/dialogs-production-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/future-architect/future-wru/2e6671993bd66a397666ec355683edaefa8ab6d0/doc/dialogs-production-mode.png -------------------------------------------------------------------------------- /doc/dialogs-session-storage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/future-architect/future-wru/2e6671993bd66a397666ec355683edaefa8ab6d0/doc/dialogs-session-storage.png -------------------------------------------------------------------------------- /doc/dialogs.drawio: -------------------------------------------------------------------------------- 1 | 5VjJcts4EP0a1ZxGxUWkpKOjeOKp8pRdVk3ZOaVAskkihggGALX46wcgwRXaHEvJYcoHA83G1u+9RkMjd7HafmEoT/+hEZCRY0Xbkft55Di2bfnyn7LsKstM9ZQhYTjSTq1hid9AGy1tLXAEvOcoKCUC531jSLMMQtGzIcbopu8WU9JfNUcJGIZliIhpfcaRSOtTTFv7HeAkrVe2/Xn1ZYVqZ30SnqKIbjom93bkLhilomqttgsgKnh1XKpxfx342myMQSbOGTBNd29vsPyOX9JvT3cPd0GQ//hTH4OLXX1giOT5dZcykdKEZojcttZPjBZZBGpWS/Zan3tKc2m0pfE7CLHTYKJCUGlKxYror7DF4qXT/qqmGnu693mrZy47O93hryDCVA8yj66jwWnBQjhyXk0+gVgC4oifZq0KRmcBHdgvQFcg2E46MCBI4HWfLEhzLmn8WlhkQyPzDpRqMXwApl78PoCZNXa8Dmz2UdCuj5PtXhooPfSRYrlnx9KJzHE1BDqNeTUk9RTVTvWoFu4bxtCu45YrB354HXfm9tdxB6I+ta++v2xUO2i518Tk5+mowVkjUuh4Pj/9O3J8IpH6FDDZSlQrZ3S7M4jbp+UmxQKWOSrJsJFXSJ91MSZkQQll5Vg3QjCLQ8VmwegrdL744QyCWH5ZAxNYpu8bgpNMfhOK32dmDzUWtkdpVMM06Yfdnev+pr0j7Km2pd37wRnw5mI5wjNAMXNGFt2oK1H2QoI4x+FA45ncyEstZNXpZGbVbVVe9t6Xm09q2TlTyp0Ye3tCXNs+qnivD/HEGyBX5TBD8cZEk0NcOZE6LqVW3yDGV7lzU67PEEi3JbA1lmq8pGzjOHbCvbKN/MD3/CsI1JsN0HP2CNTZwx7/WvqcGTAsQUqQZiYSS0GZKkuHGMgKMlfNcEewBIO5p5EIKtjug8aAwtekBPOhEHIa0HZeXfz2HvgCO4piax98tjV153CN/OpN+vBNTPj8X4ne3EDv70geD4udCd8TJJgLYP9j/CaDsuS342ebNctSXofS8gQ/CuDCAOsEMDWWq22iHr7jAMnrdEzl/N9k9UFoIa4Q1uYtWV8l3p6sZv3SuLqny46y5gV2uwZV+jbhqx7DipcR4mlztXRj3ONrRhXZh+T2LPUn7QQFQB4px0LlVCkyUIt2KsL7gUNAhaCrgyUjreS1aH5XUIsg7dJMPqQBjWN5e44jUJcoHxOUDwrQo8+i85lgzwcCc00meDOTCO7kWkQw68+HjJRPn0I+NplMT0ptBQf2By+rs1J2vAwE54dzZSOmE4LMgWF5FIVKPeixNfWl2BYzlyo3rIEwzy03ZlcT5nRPwiOKyRUI5rUlX/yORWiCs98JRq4F6iitjOfOZfDxh9Xg5Ex4/HfDI7vtb3pVDd/+Mure/gc=7VpbT+M4FP411T5NlXvTRyjszmgYgVrtAE/ISZzG4MZZx6Xp/Pq1G+dmB1KgLavdFVKJT47t5Hzn8vm0I3u2Kv6gIEt+kAjikWVExci+GFmWaRoe/yck21Lii5EQLCmKpFIjWKBfUAoNKV2jCOYdRUYIZijrCkOSpjBkHRmglGy6ajHB3V0zsISaYBECrEtvUcSS6i0mjfwrRMuk2tn0puWdFaiU5ZvkCYjIpiWyL0f2jBLCyqtVMYNYGK+yS2E/bBJgnC/ch8K5AM7MTJMv5WK/v2VK/QoUpuzdS1uUka/WXfzdN4r55R1YPfqFnGI8A7yW9roiIBpZHuYbnQeUXy3F1TnAIA0hlZZg28q8lKzTCIotDK62SRCDiwyE4u6GOxSXJWyF+cjklzHCeEYwobu5duyHMAy5PGeUPMHWncB3HVcsmD9BFiZyunxWSBksFHAHLGPWcHE/h2QFGd3yeXIVy5FmkC7uVIhvGofxpChp+0qlB6SPLuulGxz4hYSiH5Yfaz93H78Xd9Mv19v7+U/88GtVwdKyM4y4V8shoSwhS5ICfNlIz7tINDpXhGTSgI+Qsa0MUbBmpIsOLBC7a13fi6XGrhxdFHLl3WArBz347AcGfzmypiF8xQgyzzBAl5C9oueUesJCLa/Q0aYQA4aeu3nh4NCZH4auY9MP4GiMLbcFpfkqkJ+EnX9o7OTUG4L4ezQRbncj3DWVyC0fVM5qPOCMUrBtqWVCIX95H9u3u/tMlJys6JtT4zV9flE+QeOOtU3e76GWlvNv53/qKT+jpNgeNN9HAPpxb773Qh8GMb8jkjvixfsMo2XK7zHh8x/JMlq1eLEK2EoVsKd6FTAnJy0DtoaUnlzS6EywJD4KMchzFCrJIOUPcldFvBi00roYNulgNzpAYh8Meqs36PfIKn4/gC2A3B58KtlHc4irsARXgb18fC2HaAs5LznaQDI6VPw7mlfd8yfXE8AtDLjaAtJnxFE5KPGLY6uf+EVe4LneqULe9YeJXx3dbZfyjhXxrobNAvKgJqkOz4IRKs4+KjD8mJKJy3CLEUeI2sPwBCWWV0EtAOHTcofw9ZrxZaCU5yXnMHswDcwoio0+TE1jYk/hydK463Qw9cz9yPzRIPU0SL9F/J0R2+qYzuES5aznmPUfB9VR+JtjTz4X1ElPnKb83GzM4V9rmDMNwAGwKnxXxVK0YcYB4JV8TPj6D5wNYbJmpzK1pcSP7ffkROOUtvaHWdCOl0N6+QwFPa9NWrZrhP9GIE/qYtW2e8evUyKCQg0C1xB/XI5BAPENyRETCZkHIxSbtljrlaIQEMbI6kVaS8ownNWdL7EJkCr14qprkDjm9XgcQVGW8zEGmUKS336c2987TJUE2bp3uL7uHLZzJOeYas5xneLdkW3ND86UpzYRlesc0t/yHQfchWe+M06ev5xn66AbCNwMUsTfRCBVTbppRN2QbSjTUUmNodDLfUmNf6wAripwC6Q5jBAVTi+6wfzj24VeDG84XY8JXVU6NaKfiVomo9sSgTb27V1dzcUultGupUZLd1QduI4F+XSqIG7th7g5cY8FualBfo5J8D+veZ3XfDpZNfXmkIbZYMuh7j+22491x7G3/SgGrQg8em+58s/BxuNpegv29FC9Ba+7UF0MTtRbMN/bssr5g7GTuNWBPcn9lzqSqTjSnh3zgzmS3qXq5QiUPKOo58A8UERAnpWUO0aFqO1Kle+kHrV5BaIJ6P/W0vFcwzkm+7a7hd71Xa1c+Cdldnq/6lDhfszsLw8og41qc9+vFk/UgVaYvTN5b3CrX3ifukroPbEzTu5FVywEO8b8j2H4U+uoR7VuODv7ntS8N8czHza/VClxbH7vY1/+DQ==5Vltc+I2EP41TD+FsS1szEeOcHdzzU3T0DSXTx3ZlkGNsIgsAtyvv5Utv4oESGJ6bSczibWW1vY+zz7aVXpostx+Eni1+MojwnqOFW176LLnOLZtefBHWXa5xVcjZZgLGulJlWFGvxNttLR1TSOSNiZKzpmkq6Yx5ElCQtmwYSH4pjkt5qz51BWeE8MwCzEzrXc0koviK4aV/TOh80XxZNsb5XeWuJisvyRd4IhvaiY07aGJ4FzmV8vthDAVvCIuN4/j2a/Rn7fxt+l4HPz+kI4mXy9yZx9PWVJ+giCJfLVrZyKXO/THw5fH8Xf55XYXOpvPFxrLJ8zWOl53N7c9x2PwnA+BgKu5uloJvt3pKMhdEVrB10lElHsL5mwWVJLZCofq7gbIBLaFXDIY2XAZU8YmnHGRrUURJn4cgj2Vgj+Q2h0v9EkQw50nIiQFIMeMzhO4J7lymT4QGS600yMDowOoHJJtjRY6UJ8IXxIp4AMtfRcNNOaa9Gikx5uKQk4xZ1GnT0EWrGk7L31X0MCFRucEpCwj/CQCoushF3LB5zzBbFpZPzQBquZccRXKLIJ/Eyl3OmvxWvImaI1gky2V32rX98pv39Wjy61+TDbY6cFpAKV8LULyQhBsPVF9+os4CsKwpE9NDdiHiV56zSm8YIm/18J/MBg2XUgs5kTqVS1ky9d4PdjISEsT/SQaK3mEUchwmtLwBehOg+FgdGtZ4O5JgsL2RhBKPwUIbiu3croYIBiOkH/AUcdoDgw07+HNTZW9IwFMmxHxRCEL3lNt4zh2wr1qG3mB53rn0lUzr0xdLSW0TimvK1n1DGxmBJKJJyY8l1jiAKcmMlAYrNRluGMUIBLoMD5BDuZVUBpw+DDPIP5tLcENKdI4l2Z7D6iBHUWxtQ9U2xqiETkXqAOrCarrmqB658R0uAfTJALLDXlck1QaAB4Aq8B3uZ2rGrkPJKBhn4P/v6A8YXwtzxXqshYt6pI9obatc8baP7xTrZSuEjF9gmCkVUjzWlrxN8LpohS2etwbvE64Sop2EriW+gE7wwFh1zylUiUvJCNRD62VkVetCQGXki+frTN5noaTsi1RD8F6Sum8TQ0ex6Dd/YgoCU/7DK9aVevpRdHx7LB9p7XP2QY7XN8kBxp0RI6RQY6su7BwFKUZR7ggKi/TQnKtCDQ26xCz8iHL1l/S7HUxCKvq/yjLlpjyHHN1f5ftrNYm20rTZ7bSUrDL7D2gACsiKIREQV4suq5Mzdyv9ulORbdVHCG0Rwn8PUow6koJitq8hvYNoKYelYkvgG6C1kb+2ZrHPgzSeTrDdi2JnOMUGHUWd7OHN+J4qFkoWjurbw+Khu6+V+vnjOYO9e2RHtcyIZvzBhwONoC6XPtJOpTyWKBdzp7aoQysUdOR5zQdddyh2MikzL/gdCGFsEiT2Jn5I2Uluyv26yICLPp+F4cU7pEkfeshBWqxz3fPSxqzr31JeHTon8P/eCxryJWV43/nkMP3XykhzgFHXbPB/T9s/0aUh0c2YN1t/94/Lt3vJMMAldjlVYjjFuP7cjEMql0gG73ukDnPg59Bv9uHYaPj9NtM/YHbcOQeeVD6bqlvHrjkfR40a5LorqzdlGVt3Z1YX8yIvNBnbheXeftXdHttuciVJGsb1Woq1e+QQy+emIc6Z2zysAh1ptiKjyt9yOBkXLa8LvXIa529Wa6pR/vaQP90PYJh9c/OnDvVv4zR9Ac= -------------------------------------------------------------------------------- /doc/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/future-architect/future-wru/2e6671993bd66a397666ec355683edaefa8ab6d0/doc/logo-small.png -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/future-architect/future-wru/2e6671993bd66a397666ec355683edaefa8ab6d0/doc/logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/future-architect/future-wru 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/coreos/go-oidc v2.2.1+incompatible 7 | github.com/future-architect/gocloudurls v1.0.4 8 | github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 9 | github.com/go-chi/chi/v5 v5.0.3 10 | github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f 11 | github.com/google/go-github v17.0.0+incompatible 12 | github.com/google/go-querystring v1.1.0 // indirect 13 | github.com/gookit/color v1.4.2 14 | github.com/kelseyhightower/envconfig v1.4.0 15 | github.com/mssola/user_agent v0.5.3 16 | github.com/oschwald/geoip2-golang v1.5.0 17 | github.com/pquerna/cachecontrol v0.1.0 // indirect 18 | github.com/rs/xid v1.3.0 19 | github.com/shibukawa/uuid62 v0.0.0-20190628130809-2b77c8679a0f 20 | github.com/stretchr/testify v1.7.0 21 | github.com/ymotongpoo/datemaki v0.0.0-20210720235720-959860789111 22 | gocloud.dev v0.23.0 23 | gocloud.dev/docstore/mongodocstore v0.23.0 24 | golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c 25 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | type wruHandler struct { 12 | c *Config 13 | s SessionStorage 14 | ir *IdentityRegister 15 | } 16 | 17 | func (wh wruHandler) Login(w http.ResponseWriter, r *http.Request) { 18 | if wh.c.DevMode { 19 | pages.ExecuteTemplate(w, "debug_login.html", &debugLoginPageContext{ 20 | Users: wh.ir.AllUsers(), 21 | }) 22 | } else { 23 | pages.ExecuteTemplate(w, "login.html", &loginPageContext{ 24 | Twitter: wh.c.Twitter.Available(), 25 | GitHub: wh.c.GitHub.Available(), 26 | OIDC: wh.c.OIDC.Available(), 27 | }) 28 | } 29 | } 30 | 31 | func (wh wruHandler) DebugLogin(w http.ResponseWriter, r *http.Request) { 32 | id, _ := GetSession(r) 33 | err := r.ParseForm() 34 | if err != nil { 35 | http.Error(w, "http request error: "+err.Error(), http.StatusInternalServerError) 36 | return 37 | } 38 | userID := r.Form.Get("userid") 39 | user, err := wh.ir.FindUserByID(userID) 40 | if err != nil { 41 | http.Error(w, "user not found: "+userID, http.StatusNotFound) 42 | } 43 | loginInfo := map[string]string{ 44 | "login-idp": "debug", 45 | } 46 | newID, oldInfo, err := wh.s.StartSession(r.Context(), id, user, r, loginInfo) 47 | if err != nil { 48 | http.Error(w, "login error: "+err.Error(), http.StatusBadRequest) 49 | return 50 | } 51 | log.Printf("🐣 login as %s\n", userID) 52 | setSessionID(r.Context(), w, newID, wh.c, ActiveSession) 53 | if u, ok := oldInfo["landingURL"]; ok { 54 | http.Redirect(w, r, u, http.StatusFound) 55 | } else { 56 | http.Redirect(w, r, wh.c.DefaultLandingPage, http.StatusFound) 57 | } 58 | } 59 | 60 | func (wh wruHandler) FederatedLogin(w http.ResponseWriter, r *http.Request) { 61 | idp := chi.URLParam(r, "provider") 62 | oldSessionID, _ := GetSession(r) 63 | if oldSessionID == "" { 64 | var err error 65 | oldSessionID, err = wh.s.StartLogin(r.Context(), map[string]string{ 66 | "landingURL": wh.c.DefaultLandingPage, 67 | }) 68 | if err != nil { 69 | http.Error(w, "session storage access error: "+err.Error(), http.StatusInternalServerError) 70 | return 71 | } 72 | } 73 | var redirectUrl string 74 | var loginInfo map[string]string 75 | var err error 76 | switch idp { 77 | case "twitter": 78 | if !wh.c.Twitter.Available() { 79 | http.Error(w, "Twitter login is not configured", http.StatusBadRequest) 80 | return 81 | } 82 | redirectUrl, loginInfo, err = twitterLoginStart(wh.c) 83 | case "github": 84 | if !wh.c.GitHub.Available() { 85 | http.Error(w, "GitHub login is not configured", http.StatusBadRequest) 86 | return 87 | } 88 | redirectUrl, loginInfo, err = gitHubLoginStart(wh.c) 89 | case "oidc": 90 | if !wh.c.OIDC.Available() { 91 | http.Error(w, "OpenID Connect login is not configured", http.StatusBadRequest) 92 | return 93 | } 94 | redirectUrl, loginInfo, err = oidcLoginStart(wh.c) 95 | default: 96 | http.Error(w, "undefined provider: "+idp, http.StatusBadRequest) 97 | return 98 | } 99 | if err != nil { 100 | http.Error(w, "can't start login sequence: "+err.Error(), http.StatusInternalServerError) 101 | return 102 | } 103 | newSessionID, err := wh.s.AddLoginInfo(r.Context(), oldSessionID, loginInfo) 104 | if err != nil { 105 | http.Error(w, "session storage access error: "+err.Error(), http.StatusBadRequest) 106 | return 107 | } 108 | setSessionID(r.Context(), w, newSessionID, wh.c, BeforeLogin) 109 | http.Redirect(w, r, redirectUrl, http.StatusFound) 110 | } 111 | 112 | func (wh wruHandler) Callback(w http.ResponseWriter, r *http.Request) { 113 | id, ses, _ := lookupSessionFromRequest(wh.c, wh.s, r) 114 | idpName := ses.Data["idp"] 115 | var idpUser string 116 | var err error 117 | var idp IDPlatform 118 | var newLoginInfo map[string]string 119 | switch idpName { 120 | case "twitter": 121 | if !wh.c.Twitter.Available() { 122 | http.Error(w, "Twitter login is not configured", http.StatusBadRequest) 123 | return 124 | } 125 | idpUser, newLoginInfo, err = twitterCallback(wh.c, r, ses.Data) 126 | idp = Twitter 127 | case "github": 128 | if !wh.c.GitHub.Available() { 129 | http.Error(w, "GitHub login is not configured", http.StatusBadRequest) 130 | return 131 | } 132 | idp = GitHub 133 | idpUser, newLoginInfo, err = githubCallback(wh.c, r, ses.Data) 134 | case "oidc": 135 | if !wh.c.GitHub.Available() { 136 | http.Error(w, "OpenID Connect login is not configured", http.StatusBadRequest) 137 | return 138 | } 139 | idp = OIDC 140 | idpUser, newLoginInfo, err = oidcCallback(wh.c, r, ses.Data) 141 | default: 142 | http.Error(w, "undefined provider: "+idpName, http.StatusBadRequest) 143 | return 144 | } 145 | 146 | user, err := wh.ir.FindUserOf(idp, idpUser) 147 | if err != nil { 148 | http.Error(w, "user not found: "+idpUser+" of "+idpName, http.StatusNotFound) 149 | return 150 | } 151 | 152 | newID, oldInfo, err := wh.s.StartSession(r.Context(), id, user, r, newLoginInfo) 153 | setSessionID(r.Context(), w, newID, wh.c, ActiveSession) 154 | log.Printf("🐣 login as %s of %s\n", idpUser, idpName) 155 | setSessionID(r.Context(), w, newID, wh.c, ActiveSession) 156 | if u, ok := oldInfo["landingURL"]; ok { 157 | http.Redirect(w, r, u, http.StatusFound) 158 | } else { 159 | http.Redirect(w, r, wh.c.DefaultLandingPage, http.StatusFound) 160 | } 161 | } 162 | 163 | func (wh wruHandler) Confirm(w http.ResponseWriter, r *http.Request) { 164 | http.Error(w, "not implemented", http.StatusInternalServerError) 165 | } 166 | 167 | func (wh wruHandler) ConfirmAction(w http.ResponseWriter, r *http.Request) { 168 | http.Error(w, "not implemented", http.StatusInternalServerError) 169 | } 170 | 171 | func (wh wruHandler) Logout(w http.ResponseWriter, r *http.Request) { 172 | id, _ := GetSession(r) 173 | err := wh.s.Logout(r.Context(), id) 174 | if err != nil { 175 | if isHTML(r) { 176 | http.Redirect(w, r, "/.wru/login?logout_error", http.StatusFound) 177 | } else { 178 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 179 | w.WriteHeader(http.StatusBadRequest) 180 | io.WriteString(w, `{"status": "error"}`) 181 | } 182 | return 183 | } 184 | removeSessionID(w, wh.c) 185 | if isHTML(r) { 186 | http.Redirect(w, r, "/.wru/login", http.StatusFound) 187 | } else { 188 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 189 | io.WriteString(w, `{"status": "ok"}`) 190 | } 191 | } 192 | 193 | func (wh wruHandler) User(w http.ResponseWriter, r *http.Request) { 194 | _, ses := GetSession(r) 195 | u, err := wh.ir.FindUserByID(ses.UserID) 196 | 197 | if err != nil { 198 | http.Error(w, "user not found: "+ses.UserID, http.StatusNotFound) 199 | return 200 | } 201 | 202 | if isHTML(r) { 203 | pages.ExecuteTemplate(w, "user_status.html", u) 204 | } else { 205 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 206 | u.WriteAsJson(w) 207 | } 208 | } 209 | 210 | func (wh wruHandler) Sessions(w http.ResponseWriter, r *http.Request) { 211 | sid, ses := GetSession(r) 212 | sessions, err := wh.s.GetUserSessions(r.Context(), ses.UserID) 213 | if err != nil { 214 | http.Error(w, "user not found: "+ses.UserID, http.StatusNotFound) 215 | return 216 | } 217 | for i, s := range sessions { 218 | if s.ID == sid { 219 | sessions[i].CurrentSession = true 220 | break 221 | } 222 | } 223 | 224 | if isHTML(r) { 225 | pages.ExecuteTemplate(w, "user_sessions.html", sessions) 226 | } else { 227 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 228 | AllUserSessions(sessions).WriteAsJson(w) 229 | } 230 | } 231 | 232 | func (wh wruHandler) SessionLogout(w http.ResponseWriter, r *http.Request) { 233 | currentID, _ := GetSession(r) 234 | targetID := chi.URLParam(r, "sessionID") 235 | if currentID == targetID { 236 | http.Error(w, "target session ID should not be as same as current ID", http.StatusBadRequest) 237 | return 238 | } 239 | err := wh.s.Logout(r.Context(), targetID) 240 | if err != nil { 241 | if isHTML(r) { 242 | http.Redirect(w, r, "/.wru/user/sessions?logout_error", http.StatusFound) 243 | } else { 244 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 245 | w.WriteHeader(http.StatusBadRequest) 246 | io.WriteString(w, `{"status": "error"}`) 247 | } 248 | return 249 | } 250 | if isHTML(r) { 251 | http.Redirect(w, r, "/.wru/user/sessions", http.StatusFound) 252 | } else { 253 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 254 | io.WriteString(w, `{"status": "ok"}`) 255 | } 256 | } 257 | 258 | func authMiddleware(c *Config, s SessionStorage, u *IdentityRegister) func(http.Handler) http.Handler { 259 | return func(next http.Handler) http.Handler { 260 | r := newHandler(c, s, u) 261 | r.NotFound(func(w http.ResponseWriter, r *http.Request) { 262 | sid, ses, ok := lookupSessionFromRequest(c, s, r) 263 | if !ok || (ses.Status != ActiveSession) { 264 | if r.RequestURI == "/favicon.ico" { 265 | http.Error(w, "not found", http.StatusNotFound) 266 | return 267 | } 268 | startSessionAndRedirect(c, s, w, r) 269 | return 270 | } 271 | next.ServeHTTP(w, setSessionInfo(r, sid, ses)) 272 | }) 273 | return r 274 | } 275 | } 276 | 277 | func newHandler(c *Config, s SessionStorage, u *IdentityRegister) *chi.Mux { 278 | wh := &wruHandler{ 279 | c: c, 280 | s: s, 281 | ir: u, 282 | } 283 | r := chi.NewRouter() 284 | r.Route("/.wru", func(r chi.Router) { 285 | r.With(MustNotLogin(c, s)).Get("/login", wh.Login) 286 | if c.DevMode { 287 | r.With(MustNotLogin(c, s)).Post("/login", wh.DebugLogin) 288 | } else { 289 | r.With(MustNotLogin(c, s)).Get("/login/{provider}", wh.FederatedLogin) 290 | r.With(MustNotLogin(c, s)).Get("/callback", wh.Callback) 291 | } 292 | r.With(MustLogin(c, s)).Get("/logout", wh.Logout) 293 | r.With(MustLogin(c, s)).Get("/user", wh.User) 294 | r.With(MustLogin(c, s)).Get("/user/sessions", wh.Sessions) 295 | r.With(MustLogin(c, s)).Post("/user/sessions/{sessionID}/logout", wh.SessionLogout) 296 | }) 297 | return r 298 | } 299 | 300 | func NewIdentityAwareProxyHandler(c *Config, s SessionStorage, u *IdentityRegister) (http.Handler, error) { 301 | h, err := NewReverseProxy(c, s) 302 | if err != nil { 303 | return nil, err 304 | } 305 | return authMiddleware(c, s, u)(h), nil 306 | } 307 | -------------------------------------------------------------------------------- /handler_helper.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/golang/gddo/httputil" 10 | ) 11 | 12 | func isHTML(r *http.Request) bool { 13 | return httputil.NegotiateContentType(r, []string{"text/html", "application/json"}, "text/html") == "text/html" 14 | } 15 | 16 | type loginInfoKeyType string 17 | 18 | const ( 19 | loginInfoKey loginInfoKeyType = "loginInfo" 20 | ) 21 | 22 | type loginInfo struct { 23 | sid string 24 | ses *Session 25 | } 26 | 27 | func setSessionInfo(r *http.Request, sid string, ses *Session) *http.Request { 28 | return r.WithContext(context.WithValue(r.Context(), loginInfoKey, &loginInfo{ 29 | sid: sid, 30 | ses: ses, 31 | })) 32 | } 33 | 34 | func GetSession(r *http.Request) (sid string, ses *Session) { 35 | if li, ok := r.Context().Value(loginInfoKey).(*loginInfo); ok { 36 | return li.sid, li.ses 37 | } 38 | return "", nil 39 | } 40 | 41 | func MustLogin(c *Config, s SessionStorage) func(http.Handler) http.Handler { 42 | return func(next http.Handler) http.Handler { 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | sid, ses, ok := lookupSessionFromRequest(c, s, r) 45 | if !ok || (ses.Status != ActiveSession && ses.Status != BeforeLogin) { 46 | startSessionAndRedirect(c, s, w, r) 47 | return 48 | } 49 | next.ServeHTTP(w, setSessionInfo(r, sid, ses)) 50 | }) 51 | } 52 | } 53 | 54 | func MustNotLogin(c *Config, s SessionStorage) func(http.Handler) http.Handler { 55 | return func(next http.Handler) http.Handler { 56 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | sid, ses, ok := lookupSessionFromRequest(c, s, r) 58 | if ok && ses.Status == ActiveSession { 59 | http.Redirect(w, r, c.DefaultLandingPage, http.StatusFound) 60 | return 61 | } 62 | next.ServeHTTP(w, setSessionInfo(r, sid, ses)) 63 | }) 64 | } 65 | 66 | } 67 | 68 | func setSessionID(ctx context.Context, w http.ResponseWriter, sessionID string, c *Config, status SessionStatus) { 69 | now := currentTime(ctx) 70 | var expires time.Time 71 | if status == BeforeLogin { 72 | expires = now.Add(c.LoginTimeoutTerm) 73 | } else { 74 | expires = now.Add(c.SessionAbsoluteTimeoutTerm) 75 | } 76 | http.SetCookie(w, &http.Cookie{ 77 | Name: c.ClientSessionKey, 78 | Value: sessionID, 79 | Path: "/", 80 | Domain: "", 81 | Expires: expires, 82 | Secure: strings.HasPrefix(c.Host, "https://"), 83 | HttpOnly: c.ClientSessionFieldCookie == CookieField, 84 | SameSite: http.SameSiteLaxMode, 85 | }) 86 | } 87 | 88 | func removeSessionID(w http.ResponseWriter, c *Config) { 89 | http.SetCookie(w, &http.Cookie{ 90 | Name: c.ClientSessionKey, 91 | Value: "", 92 | Path: "/", 93 | Domain: "", 94 | Expires: time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC), 95 | Secure: strings.HasPrefix(c.Host, "https://"), 96 | HttpOnly: c.ClientSessionFieldCookie == CookieField, 97 | SameSite: http.SameSiteLaxMode, 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /identityregister.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "net/url" 10 | "os" 11 | "path" 12 | "regexp" 13 | "sort" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/future-architect/gocloudurls" 19 | "github.com/gookit/color" 20 | "gocloud.dev/blob" 21 | ) 22 | 23 | type IDPlatform string 24 | 25 | const ( 26 | Twitter IDPlatform = "Twitter" 27 | GitHub IDPlatform = "GitHub" 28 | OIDC IDPlatform = "OIDC" 29 | ) 30 | 31 | var ( 32 | ErrUserNotFound = errors.New("user not found") 33 | ErrNotModified = errors.New("not modified") 34 | ) 35 | 36 | type FederatedAccount struct { 37 | Service IDPlatform `json:"service"` 38 | Account string `json:"account"` 39 | } 40 | 41 | type User struct { 42 | DisplayName string `json:"display_name"` 43 | Organization string `json:"organization"` 44 | UserID string `json:"user_id"` 45 | Email string `json:"email"` 46 | Scopes []string `json:"scopes"` 47 | FederatedUserAccounts []FederatedAccount `json:"federated_accounts"` 48 | } 49 | 50 | func (u User) ScopeString() string { 51 | return strings.Join(u.Scopes, ", ") 52 | } 53 | 54 | func (u User) WriteAsJson(w io.Writer) error { 55 | e := json.NewEncoder(w) 56 | return e.Encode(&u) 57 | } 58 | 59 | type IdentityRegister struct { 60 | fromID map[string]*User 61 | fromIDPUser map[IDPlatform]map[string]*User 62 | sourceBlobUrl string 63 | fileModifiedAt time.Time 64 | lock *sync.RWMutex 65 | } 66 | 67 | func (ir IdentityRegister) AllUsers() []*User { 68 | ir.lock.RLock() 69 | defer ir.lock.RUnlock() 70 | result := make([]*User, 0, len(ir.fromID)) 71 | for _, u := range ir.fromID { 72 | result = append(result, u) 73 | } 74 | sort.Slice(result, func(i, j int) bool { 75 | return result[i].UserID < result[j].UserID 76 | }) 77 | return result 78 | } 79 | 80 | func (ir *IdentityRegister) FindUserByID(userID string) (*User, error) { 81 | ir.lock.RLock() 82 | defer ir.lock.RUnlock() 83 | u, ok := ir.fromID[userID] 84 | if !ok { 85 | return nil, ErrUserNotFound 86 | } 87 | return u, nil 88 | } 89 | 90 | func (ir *IdentityRegister) FindUserOf(idp IDPlatform, userID string) (*User, error) { 91 | ir.lock.RLock() 92 | defer ir.lock.RUnlock() 93 | if idpUsers, ok := ir.fromIDPUser[idp]; ok { 94 | if u, ok := idpUsers[userID]; ok { 95 | return u, nil 96 | } 97 | } 98 | return nil, ErrUserNotFound 99 | } 100 | 101 | var userRE = regexp.MustCompile(`WRU_USER_\d+=(.*)`) 102 | 103 | func NewIdentityRegister(ctx context.Context, c *Config, out io.Writer) (*IdentityRegister, []string, error) { 104 | if c != nil && c.UserTable != "" { 105 | return NewIdentityRegisterFromConfig(ctx, c, out) 106 | } 107 | return NewIdentityRegisterFromEnv(ctx, os.Environ(), out) 108 | } 109 | 110 | func NewIdentityRegisterFromConfig(ctx context.Context, c *Config, out io.Writer) (*IdentityRegister, []string, error) { 111 | ir := &IdentityRegister{ 112 | fromID: make(map[string]*User), 113 | fromIDPUser: map[IDPlatform]map[string]*User{}, 114 | lock: &sync.RWMutex{}, 115 | } 116 | var warnings []string 117 | if !strings.HasPrefix(c.UserTable, ".") && !strings.HasPrefix(c.UserTable, "/") { 118 | var err error 119 | c.UserTable, err = gocloudurls.NormalizeBlobURL(c.UserTable, os.Environ()) 120 | if err != nil { 121 | return nil, nil, err 122 | } 123 | } 124 | ir.sourceBlobUrl = c.UserTable 125 | users, modTime, err := readUsersFromBlob(ctx, c.UserTable, ir.fileModifiedAt) 126 | if err != nil { 127 | return nil, nil, err 128 | } 129 | ir.fileModifiedAt = modTime 130 | for _, u := range users { 131 | ir.appendUser(u) 132 | } 133 | if out != nil { 134 | color.Fprintf(out, "Read %d users from %s\n", len(users), c.UserTable) 135 | } 136 | if c.UserTableReloadTerm > time.Second { 137 | go func() { 138 | t := time.NewTicker(c.UserTableReloadTerm) 139 | defer t.Stop() 140 | for { 141 | select { 142 | case <-ctx.Done(): 143 | return 144 | case <-t.C: 145 | ir2 := &IdentityRegister{ 146 | fromID: make(map[string]*User), 147 | fromIDPUser: map[IDPlatform]map[string]*User{}, 148 | } 149 | users, modTime, err := readUsersFromBlob(ctx, c.UserTable, ir.fileModifiedAt) 150 | if err != nil { 151 | if !errors.Is(err, ErrNotModified) { 152 | if out != nil { 153 | color.Fprintf(out, "Reload user table error: %s\n", err.Error()) 154 | } 155 | return 156 | } else { 157 | // log.Println("file is not modified") 158 | continue 159 | } 160 | } 161 | for _, u := range users { 162 | ir2.appendUser(u) 163 | } 164 | color.Fprintf(out, "Read %d users from %s\n", len(users), c.UserTable) 165 | ir.lock.Lock() 166 | ir.fromID = ir2.fromID 167 | ir.fromIDPUser = ir2.fromIDPUser 168 | ir.fileModifiedAt = modTime 169 | ir.lock.Unlock() 170 | } 171 | } 172 | }() 173 | } 174 | return ir, warnings, nil 175 | } 176 | 177 | func NewIdentityRegisterFromEnv(ctx context.Context, envs []string, out io.Writer) (*IdentityRegister, []string, error) { 178 | ir := &IdentityRegister{ 179 | fromID: make(map[string]*User), 180 | fromIDPUser: map[IDPlatform]map[string]*User{}, 181 | lock: &sync.RWMutex{}, 182 | } 183 | var warnings []string 184 | 185 | if out != nil { 186 | color.Fprintf(out, "Users (for DevMode):\n") 187 | } 188 | 189 | for _, env := range envs { 190 | u := parseUserFromEnv(env) 191 | if u != nil { 192 | ir.appendUser(u) 193 | if out != nil { 194 | color.Fprintf(out, " '%s'(%s) @ %s (scopes: %s)\n", u.DisplayName, u.UserID, u.Organization, strings.Join(u.Scopes, ", ")) 195 | } 196 | } 197 | } 198 | return ir, warnings, nil 199 | } 200 | 201 | func (ir *IdentityRegister) appendUser(u *User) { 202 | ir.fromID[u.UserID] = u 203 | for _, service := range u.FederatedUserAccounts { 204 | if service.Service != "" { 205 | if _, ok := ir.fromIDPUser[service.Service]; !ok { 206 | ir.fromIDPUser[service.Service] = make(map[string]*User) 207 | } 208 | ir.fromIDPUser[service.Service][service.Account] = u 209 | } 210 | } 211 | } 212 | 213 | func SplitBlobPath(resourceUrl string) (string, string, error) { 214 | u, err := url.Parse(resourceUrl) 215 | if err != nil { 216 | return "", "", err 217 | } 218 | if u.Scheme != "file" { 219 | resourcePath := u.Path 220 | u.Path = "" 221 | return u.String(), resourcePath, nil 222 | } else { 223 | dir := path.Dir(u.Path) 224 | base := path.Base(u.Path) 225 | u.Path = dir 226 | return u.String(), base, nil 227 | } 228 | } 229 | 230 | func readUsersFromBlob(ctx context.Context, path string, modifiedAt time.Time) ([]*User, time.Time, error) { 231 | if !strings.HasPrefix(path, ".") && !strings.HasPrefix(path, "/") { 232 | bucketUrl, res, err := SplitBlobPath(path) 233 | if err != nil { 234 | return nil, time.Time{}, err 235 | } 236 | b, err := blob.OpenBucket(ctx, bucketUrl) 237 | if err != nil { 238 | return nil, time.Time{}, err 239 | } 240 | defer b.Close() 241 | 242 | a, err := b.Attributes(ctx, res) 243 | if err != nil { 244 | return nil, time.Time{}, err 245 | } 246 | if modifiedAt.Before(a.ModTime) { 247 | r, err := b.NewReader(ctx, res, &blob.ReaderOptions{}) 248 | if err != nil { 249 | return nil, time.Time{}, err 250 | } 251 | defer r.Close() 252 | users, err := parseUsersFromBlob(r) 253 | if err != nil { 254 | return nil, time.Time{}, err 255 | } 256 | return users, a.ModTime, err 257 | } 258 | } else { 259 | s, err := os.Stat(path) 260 | if err != nil { 261 | return nil, time.Time{}, err 262 | } 263 | if modifiedAt.Before(s.ModTime()) { 264 | f, err := os.Open(path) 265 | if err != nil { 266 | return nil, time.Time{}, err 267 | } 268 | defer f.Close() 269 | users, err := parseUsersFromBlob(f) 270 | if err != nil { 271 | return nil, time.Time{}, err 272 | } 273 | return users, s.ModTime(), nil 274 | } 275 | } 276 | return nil, time.Time{}, ErrNotModified 277 | } 278 | 279 | func parseUsersFromBlob(r io.Reader) ([]*User, error) { 280 | cr := csv.NewReader(r) 281 | var result []*User 282 | headers, err := cr.Read() 283 | if err != nil { 284 | return nil, err 285 | } 286 | keys := map[int]string{} 287 | foundID := false 288 | for i, h := range headers { 289 | if h == "id" || h == "userid" { 290 | keys[i] = "id" 291 | foundID = true 292 | } else if h == "name" { 293 | keys[i] = "name" 294 | } else if h == "mail" || h == "email" { 295 | keys[i] = "mail" 296 | } else if h == "org" || h == "organization" { 297 | keys[i] = "org" 298 | } else if h == "scopes" || h == "scope" { 299 | keys[i] = "scope" 300 | } else if h == "twitter" { 301 | keys[i] = "twitter" 302 | } else if h == "github" { 303 | keys[i] = "github" 304 | } else if h == "oidc" { 305 | keys[i] = "oidc" 306 | } 307 | } 308 | if !foundID { 309 | return nil, errors.New("invalid csv: no id field") 310 | } 311 | for { 312 | records, err := cr.Read() 313 | if err == io.EOF { 314 | break 315 | } 316 | if err != nil { 317 | return nil, err 318 | } 319 | u := &User{} 320 | for i, r := range records { 321 | if key, ok := keys[i]; ok { 322 | switch key { 323 | case "id": 324 | u.UserID = r 325 | case "name": 326 | u.DisplayName = r 327 | case "mail": 328 | u.Email = r 329 | case "org": 330 | u.Organization = r 331 | case "scope": 332 | u.Scopes = strings.Split(r, ",") 333 | case "twitter": 334 | u.FederatedUserAccounts = append(u.FederatedUserAccounts, FederatedAccount{ 335 | Service: Twitter, 336 | Account: r, 337 | }) 338 | case "github": 339 | u.FederatedUserAccounts = append(u.FederatedUserAccounts, FederatedAccount{ 340 | Service: GitHub, 341 | Account: r, 342 | }) 343 | case "oidc": 344 | u.FederatedUserAccounts = append(u.FederatedUserAccounts, FederatedAccount{ 345 | Service: OIDC, 346 | Account: r, 347 | }) 348 | } 349 | } 350 | } 351 | if u.UserID != "" { 352 | result = append(result, u) 353 | } 354 | } 355 | return result, nil 356 | } 357 | 358 | func parseUserFromEnv(env string) *User { 359 | match := userRE.FindStringSubmatch(env) 360 | if match == nil { 361 | return nil 362 | } 363 | u := &User{} 364 | for _, f := range strings.Split(match[1], ",") { 365 | elems := strings.SplitN(f, ":", 2) 366 | if len(elems) == 0 { 367 | // todo: warning 368 | continue 369 | } else if len(elems) == 1 { 370 | // todo: warning 371 | continue 372 | } 373 | switch elems[0] { 374 | case "userid": 375 | fallthrough 376 | case "id": 377 | u.UserID = elems[1] 378 | case "name": 379 | u.DisplayName = elems[1] 380 | case "mail": 381 | fallthrough 382 | case "email": 383 | u.Email = elems[1] 384 | case "org": 385 | fallthrough 386 | case "organization": 387 | u.Organization = elems[1] 388 | case "scope": 389 | u.Scopes = append(u.Scopes, elems[1]) 390 | case "twitter": 391 | u.FederatedUserAccounts = append(u.FederatedUserAccounts, FederatedAccount{ 392 | Service: Twitter, 393 | Account: elems[1], 394 | }) 395 | case "github": 396 | u.FederatedUserAccounts = append(u.FederatedUserAccounts, FederatedAccount{ 397 | Service: GitHub, 398 | Account: elems[1], 399 | }) 400 | case "oidc": 401 | u.FederatedUserAccounts = append(u.FederatedUserAccounts, FederatedAccount{ 402 | Service: OIDC, 403 | Account: elems[1], 404 | }) 405 | } 406 | } 407 | if u.UserID != "" { 408 | return u 409 | } 410 | return nil 411 | } 412 | -------------------------------------------------------------------------------- /identityregister_test.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "time" 7 | 8 | // _ "gocloud.dev/blob/fileblob" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestNewLocalUserStorage(t *testing.T) { 17 | type args struct { 18 | envs []string 19 | requestUserID string 20 | } 21 | tests := []struct { 22 | name string 23 | args args 24 | want *User 25 | wantErr error 26 | }{ 27 | { 28 | name: "init over env var: empty", 29 | args: args{ 30 | envs: []string{}, 31 | requestUserID: "user1", 32 | }, 33 | wantErr: ErrUserNotFound, 34 | }, 35 | { 36 | name: "init over env var: found", 37 | args: args{ 38 | envs: []string{ 39 | `WRU_USER_1=id:user1,name:test user,mail:user1@example.com,org:R&D,scope:admin,scope:user,scope:org:rd,twitter:user1`, 40 | }, 41 | requestUserID: "user1", 42 | }, 43 | want: &User{ 44 | DisplayName: "test user", 45 | Organization: "R&D", 46 | UserID: "user1", 47 | Email: "user1@example.com", 48 | Scopes: []string{"admin", "user", "org:rd"}, 49 | FederatedUserAccounts: []FederatedAccount{ 50 | { 51 | Service: Twitter, 52 | Account: "user1", 53 | }, 54 | }, 55 | }, 56 | }, 57 | { 58 | name: "init over file: found", 59 | args: args{ 60 | envs: []string{ 61 | `WRU_USER_1=id:user1,name:test user,mail:user1@example.com,org:R&D,scope:admin,scope:user,scope:org:rd,twitter:user1`, 62 | }, 63 | requestUserID: "user1", 64 | }, 65 | want: &User{ 66 | DisplayName: "test user", 67 | Organization: "R&D", 68 | UserID: "user1", 69 | Email: "user1@example.com", 70 | Scopes: []string{"admin", "user", "org:rd"}, 71 | FederatedUserAccounts: []FederatedAccount{ 72 | { 73 | Service: Twitter, 74 | Account: "user1", 75 | }, 76 | }, 77 | }, 78 | }, 79 | } 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | s, _, _ := NewIdentityRegisterFromEnv(context.Background(), tt.args.envs, io.Discard) 83 | u, err := s.FindUserByID(tt.args.requestUserID) 84 | if tt.wantErr == nil { 85 | assert.NoError(t, err) 86 | assert.Equal(t, tt.want, u) 87 | } else { 88 | assert.Equal(t, err, tt.wantErr) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func Test_parseUsersFromBlob(t *testing.T) { 95 | type args struct { 96 | src string 97 | } 98 | tests := []struct { 99 | name string 100 | args args 101 | want []*User 102 | wantErr bool 103 | }{ 104 | { 105 | name: "no user", 106 | args: args{ 107 | src: `id,name,org,scopes,email 108 | `, 109 | }, 110 | want: nil, 111 | wantErr: false, 112 | }, 113 | { 114 | name: "no id", 115 | args: args{ 116 | src: `name,org,scopes,email 117 | `, 118 | }, 119 | want: nil, 120 | wantErr: true, 121 | }, 122 | { 123 | name: "one user", 124 | args: args{ 125 | src: `id,name,mail,org,scopes,twitter 126 | user1,test user,user1@example.com,R&D,"admin,user,org:rd",user1 127 | `, 128 | }, 129 | want: []*User{ 130 | { 131 | DisplayName: "test user", 132 | Organization: "R&D", 133 | UserID: "user1", 134 | Email: "user1@example.com", 135 | Scopes: []string{"admin", "user", "org:rd"}, 136 | FederatedUserAccounts: []FederatedAccount{ 137 | { 138 | Service: Twitter, 139 | Account: "user1", 140 | }, 141 | }, 142 | }, 143 | }, 144 | wantErr: false, 145 | }, 146 | } 147 | for _, tt := range tests { 148 | t.Run(tt.name, func(t *testing.T) { 149 | got, err := parseUsersFromBlob(strings.NewReader(tt.args.src)) 150 | if (err != nil) != tt.wantErr { 151 | t.Errorf("parseUsersFromBlob() error = %v, wantErr %v", err, tt.wantErr) 152 | return 153 | } 154 | if !reflect.DeepEqual(got, tt.want) { 155 | t.Errorf("parseUsersFromBlob() got = %v, want %v", got, tt.want) 156 | } 157 | }) 158 | } 159 | } 160 | 161 | func Test_readUsersFromBlob(t *testing.T) { 162 | type args struct { 163 | path string 164 | } 165 | tests := []struct { 166 | name string 167 | args args 168 | want []*User 169 | wantErr bool 170 | }{ 171 | { 172 | name: "from path: error", 173 | args: args{ 174 | path: "./testdata/testuser.notfound.csv", 175 | }, 176 | wantErr: true, 177 | }, 178 | { 179 | name: "from path: success", 180 | args: args{ 181 | path: "./testdata/testuser_for_ut.csv", 182 | }, 183 | want: []*User{ 184 | { 185 | DisplayName: "test user1", 186 | Organization: "R&D", 187 | UserID: "testuser1", 188 | Email: "testuser1@example.com", 189 | Scopes: []string{"admin", "user", "org:rd"}, 190 | FederatedUserAccounts: []FederatedAccount{ 191 | { 192 | Service: Twitter, 193 | Account: "testuser1", 194 | }, 195 | { 196 | Service: GitHub, 197 | Account: "testuser1", 198 | }, 199 | }, 200 | }, 201 | }, 202 | wantErr: false, 203 | }, 204 | { 205 | name: "from file scheme: error", 206 | args: args{ 207 | path: "file://./testdata/testuser.notfound.csv", 208 | }, 209 | wantErr: true, 210 | }, 211 | } 212 | for _, tt := range tests { 213 | t.Run(tt.name, func(t *testing.T) { 214 | got, _, err := readUsersFromBlob(context.Background(), tt.args.path, time.Time{}) 215 | if (err != nil) != tt.wantErr { 216 | t.Errorf("readUsersFromBlob() error = %v, wantErr %v", err, tt.wantErr) 217 | return 218 | } 219 | assert.Equal(t, tt.want, got) 220 | }) 221 | } 222 | } 223 | 224 | func TestUserStorage_FindUserAPIs(t *testing.T) { 225 | envs := []string{ 226 | `WRU_USER_1=id:user1,name:test user,mail:user1@example.com,org:R&D,scope:admin,scope:user,scope:org:rd,twitter:user1`, 227 | } 228 | us, warnings, _ := NewIdentityRegisterFromEnv(context.Background(), envs, io.Discard) 229 | assert.Nil(t, warnings) 230 | u, err := us.FindUserOf(Twitter, "user1") 231 | assert.NoError(t, err) 232 | assert.Equal(t, "user1@example.com", u.Email) 233 | } 234 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/gookit/color" 7 | "io" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | func NewAuthorizationMiddleware(ctx context.Context, c *Config, out io.Writer) (http.Handler, func(http.Handler) http.Handler) { 13 | err := c.Init(ctx, out) 14 | if err != nil { 15 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Config validation error: %s", err.Error())) 16 | os.Exit(1) 17 | } 18 | sessionStorage, err := NewSessionStorage(ctx, c, os.Stdout) 19 | if err != nil { 20 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Connect session error: %s", err.Error())) 21 | os.Exit(1) 22 | } 23 | identityRegister, warnings, err := NewIdentityRegister(ctx, c, os.Stdout) 24 | if err != nil { 25 | fmt.Fprintln(os.Stderr, color.Error.Sprintf("Read user table error: %s", err.Error())) 26 | os.Exit(1) 27 | } 28 | for _, u := range c.Users { 29 | identityRegister.appendUser(u) 30 | } 31 | for _, w := range warnings { 32 | fmt.Fprintln(os.Stderr, color.Warn.Sprintf("User parse warning: %s", w)) 33 | } 34 | handler := newHandler(c, sessionStorage, identityRegister) 35 | middleware := func(next http.Handler) http.Handler { 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | sid, ses, ok := lookupSessionFromRequest(c, sessionStorage, r) 38 | if !ok || (ses.Status != ActiveSession) { 39 | if r.RequestURI == "/favicon.ico" { 40 | http.Error(w, "not found", http.StatusNotFound) 41 | return 42 | } 43 | startSessionAndRedirect(c, sessionStorage, w, r) 44 | return 45 | } 46 | next.ServeHTTP(w, setSessionInfo(r, sid, ses)) 47 | sessionStorage.UpdateSessionData(r.Context(), sid, ses.directrives) 48 | 49 | }) 50 | } 51 | return handler, middleware 52 | } 53 | -------------------------------------------------------------------------------- /reverseproxy.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/http/httputil" 9 | "strings" 10 | ) 11 | 12 | type ProxyTransport struct { 13 | c *Config 14 | s SessionStorage 15 | } 16 | 17 | func (p ProxyTransport) RoundTrip(req *http.Request) (res *http.Response, err error) { 18 | found := false 19 | for _, f := range p.c.ForwardTo { 20 | if strings.HasPrefix(req.URL.Path, f.Path) { 21 | req.URL.Host = f.Host.Host 22 | req.URL.Scheme = f.Host.Scheme 23 | found = true 24 | break 25 | } 26 | } 27 | if !found { 28 | r := httptest.NewRecorder() 29 | r.WriteHeader(http.StatusNotFound) 30 | r.WriteString(`{"status": "not found"}`) 31 | return r.Result(), nil 32 | } 33 | sid, ses := GetSession(req) 34 | if ses != nil { 35 | sjson, _ := json.Marshal(ses) 36 | req.Header.Set(p.c.ServerSessionField, string(sjson)) 37 | } 38 | res, err = http.DefaultTransport.RoundTrip(req) 39 | if err != nil { 40 | log.Println(err) 41 | } 42 | if p.s != nil { 43 | var directives []*Directive 44 | for _, src := range res.Header.Values("Wru-Set-Session-Data") { 45 | d, err := parseDirective(src) 46 | if err != nil { 47 | return res, err 48 | } 49 | directives = append(directives, d) 50 | } 51 | p.s.UpdateSessionData(req.Context(), sid, directives) 52 | res.Header.Del("Wru-Set-Session-Data") 53 | } 54 | return res, nil 55 | } 56 | 57 | func NewReverseProxy(config *Config, s SessionStorage) (http.Handler, error) { 58 | rp := &httputil.ReverseProxy{ 59 | Director: func(req *http.Request) { 60 | }, 61 | Transport: &ProxyTransport{ 62 | c: config, 63 | s: s, 64 | }, 65 | } 66 | return rp, nil 67 | } 68 | -------------------------------------------------------------------------------- /reverseproxy_test.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func init() { 12 | log.SetFlags(log.LstdFlags | log.Lshortfile) 13 | log.SetPrefix("🦀 ") 14 | } 15 | 16 | func TestNewProxy(t *testing.T) { 17 | var lastCalled string 18 | server1 := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 19 | lastCalled = "server1" 20 | })) 21 | defer server1.Close() 22 | server2 := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 23 | lastCalled = "server2" 24 | })) 25 | defer server2.Close() 26 | p, err := NewReverseProxy(&Config{ 27 | ForwardTo: []Route{ 28 | { 29 | Host: mustParseUrl(server1.URL), 30 | Path: "/server1/", 31 | }, 32 | { 33 | Host: mustParseUrl(server2.URL), 34 | Path: "/server2/", 35 | }, 36 | }, 37 | }, nil) 38 | assert.NoError(t, err) 39 | if err != nil { 40 | return 41 | } 42 | 43 | proxy := httptest.NewServer(p) 44 | type args struct { 45 | path string 46 | } 47 | tests := []struct { 48 | name string 49 | args args 50 | wantStatus int 51 | wantLastCalled string 52 | }{ 53 | { 54 | name: "success 1", 55 | args: args{ 56 | path: "/server1/test", 57 | }, 58 | wantStatus: 200, 59 | wantLastCalled: "server1", 60 | }, 61 | { 62 | name: "success 2", 63 | args: args{ 64 | path: "/server2/test", 65 | }, 66 | wantStatus: 200, 67 | wantLastCalled: "server2", 68 | }, 69 | { 70 | name: "missing", 71 | args: args{ 72 | path: "/server3/test", 73 | }, 74 | wantStatus: 404, 75 | wantLastCalled: "", 76 | }, 77 | } 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | lastCalled = "" 81 | res1, err := http.Get(proxy.URL + tt.args.path) 82 | assert.NoError(t, err) 83 | if err != nil { 84 | return 85 | } 86 | assert.Equal(t, tt.wantStatus, res1.StatusCode) 87 | assert.Equal(t, tt.wantLastCalled, lastCalled) 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /serverless_sessionstorage.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/future-architect/gocloudurls" 8 | "github.com/mssola/user_agent" 9 | "github.com/shibukawa/uuid62" 10 | "gocloud.dev/docstore" 11 | "gocloud.dev/gcerrors" 12 | "io" 13 | "net/http" 14 | ) 15 | 16 | type ServerlessSessionStorage struct { 17 | ctx context.Context 18 | config *Config 19 | 20 | singleSessions *docstore.Collection 21 | userSessions *docstore.Collection 22 | } 23 | 24 | func NewMemorySessionStorage(ctx context.Context, config *Config, prefix string) (*ServerlessSessionStorage, error) { 25 | sSesUrl := gocloudurls.MustNormalizeDocStoreURL("mem://", gocloudurls.Option{ 26 | KeyName: "id", 27 | Collection: prefix + "singlesessions", 28 | }) 29 | sessions, err := docstore.OpenCollection(ctx, sSesUrl) 30 | if err != nil { 31 | return nil, err 32 | } 33 | uSesUrl := gocloudurls.MustNormalizeDocStoreURL("mem://", gocloudurls.Option{ 34 | KeyName: "id", 35 | Collection: prefix + "userSessions", 36 | }) 37 | users, err := docstore.OpenCollection(ctx, uSesUrl) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return &ServerlessSessionStorage{ 42 | ctx: ctx, 43 | config: config, 44 | singleSessions: sessions, 45 | userSessions: users, 46 | }, nil 47 | } 48 | 49 | func NewServerlessSessionStorage(ctx context.Context, config *Config, prefix string) (*ServerlessSessionStorage, error) { 50 | sessionUrl, err := gocloudurls.NormalizeDocStoreURL(config.SessionStorage, gocloudurls.Option{ 51 | KeyName: "id", 52 | Collection: prefix + "singleSessions", 53 | }) 54 | if err != nil { 55 | return nil, err 56 | } 57 | sessions, err := docstore.OpenCollection(ctx, sessionUrl) 58 | if err != nil { 59 | return nil, err 60 | } 61 | usersUrl, err := gocloudurls.NormalizeDocStoreURL(config.SessionStorage, gocloudurls.Option{ 62 | KeyName: "id", 63 | Collection: prefix + "singleSessions", 64 | }) 65 | if err != nil { 66 | return nil, err 67 | } 68 | users, err := docstore.OpenCollection(ctx, usersUrl) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return &ServerlessSessionStorage{ 73 | ctx: ctx, 74 | config: config, 75 | singleSessions: sessions, 76 | userSessions: users, 77 | }, nil 78 | } 79 | 80 | func (s *ServerlessSessionStorage) Close() { 81 | s.singleSessions.Close() 82 | s.userSessions.Close() 83 | } 84 | 85 | func (s ServerlessSessionStorage) StartLogin(ctx context.Context, info map[string]string) (sessionID string, err error) { 86 | sid, err := s.generateNewSessionID(ctx) 87 | if err != nil { 88 | return "", err 89 | } 90 | 91 | now := currentTime(ctx) 92 | 93 | err = s.singleSessions.Create(ctx, &SingleSessionData{ 94 | ID: sid, 95 | UserID: "", 96 | LoginAt: now, 97 | LastAccessAt: now, 98 | LoginInfo: info, 99 | }) 100 | return sid, err 101 | } 102 | 103 | func (s ServerlessSessionStorage) AddLoginInfo(ctx context.Context, oldSessionID string, info map[string]string) (newSessionID string, err error) { 104 | sid, err := s.generateNewSessionID(ctx) 105 | if err != nil { 106 | return "", err 107 | } 108 | 109 | loginSession := &SingleSessionData{ 110 | ID: oldSessionID, 111 | } 112 | 113 | err = s.singleSessions.Get(ctx, loginSession) 114 | if err != nil { 115 | return "", err 116 | } 117 | for k, v := range info { 118 | loginSession.LoginInfo[k] = v 119 | } 120 | s.singleSessions.Delete(ctx, &SingleSessionData{ 121 | ID: oldSessionID, 122 | }) 123 | loginSession.ID = sid 124 | s.singleSessions.Create(ctx, loginSession) 125 | return sid, nil 126 | } 127 | 128 | func (s *ServerlessSessionStorage) StartSession(ctx context.Context, oldSessionID string, user *User, r *http.Request, newLoginInfo map[string]string) (sessionID string, info map[string]string, err error) { 129 | loginSession := &SingleSessionData{ 130 | ID: oldSessionID, 131 | } 132 | err = s.singleSessions.Get(ctx, loginSession) 133 | if err != nil { 134 | if gcerrors.Code(err) == gcerrors.NotFound { 135 | return "", nil, errors.New("startSessionAndRedirect requires old session to login") 136 | } 137 | } 138 | s.singleSessions.Delete(ctx, &SingleSessionData{ 139 | ID: oldSessionID, 140 | }) 141 | 142 | sid, err := s.generateNewSessionID(ctx) 143 | if err != nil { 144 | return "", nil, err 145 | } 146 | 147 | ua := user_agent.New(r.Header.Get("User-Agent")) 148 | browser, version := ua.Browser() 149 | country, ip := getGeoLocation(s.config, r) 150 | loginInfo := map[string]string{ 151 | "browser": browser, 152 | "version": version, 153 | "os": ua.OS(), 154 | "platform": ua.Platform(), 155 | "country": country, 156 | "ip": ip, 157 | } 158 | for k, v := range newLoginInfo { 159 | loginInfo[k] = v 160 | } 161 | 162 | now := currentTime(ctx) 163 | err = s.singleSessions.Create(ctx, &SingleSessionData{ 164 | ID: sid, 165 | UserID: user.UserID, 166 | LoginAt: now, 167 | LastAccessAt: now, 168 | LoginInfo: loginInfo, 169 | }) 170 | if err != nil { 171 | return "", nil, fmt.Errorf("can't create session data: %w", err) 172 | } 173 | uSes := UserSession{ID: user.UserID} 174 | err = s.userSessions.Get(ctx, &uSes) 175 | if err != nil { 176 | if gcerrors.Code(err) == gcerrors.NotFound { 177 | err := s.userSessions.Create(ctx, &UserSession{ 178 | ID: user.UserID, 179 | Sessions: []string{sid}, 180 | Data: make(map[string]string), 181 | DisplayName: user.DisplayName, 182 | Email: user.Email, 183 | Organization: user.Organization, 184 | Scopes: user.Scopes, 185 | }) 186 | if err != nil { 187 | return "", nil, fmt.Errorf("can't create session data: %w", err) 188 | } 189 | } else { 190 | return "", nil, err 191 | } 192 | } else { 193 | uSes.Sessions = append(uSes.Sessions, sid) 194 | err = s.userSessions.Replace(ctx, &uSes) 195 | if err != nil { 196 | return "", nil, fmt.Errorf("can't update session data: %w", err) 197 | } 198 | } 199 | return sid, loginSession.LoginInfo, nil 200 | } 201 | 202 | func (s *ServerlessSessionStorage) generateNewSessionID(ctx context.Context) (string, error) { 203 | var sid string 204 | oneSes := SingleSessionData{} 205 | for i := 0; i < 10; i++ { 206 | sid, _ = uuid62.V4() 207 | oneSes.ID = sid 208 | err := s.singleSessions.Get(ctx, &oneSes) 209 | if err != nil { 210 | if gcerrors.Code(err) == gcerrors.NotFound { 211 | return sid, nil 212 | } else { 213 | return "", err 214 | } 215 | } 216 | } 217 | return "", errors.New("getting new session id error") 218 | } 219 | 220 | func (s ServerlessSessionStorage) Logout(ctx context.Context, sessionID string) error { 221 | sSes := SingleSessionData{ID: sessionID} 222 | err := s.singleSessions.Get(ctx, &sSes) 223 | if err != nil { 224 | return nil 225 | } 226 | uSes := UserSession{ID: sSes.UserID} 227 | err = s.userSessions.Get(ctx, &uSes) 228 | if err != nil { 229 | return nil 230 | } 231 | var sesIDs []string 232 | for _, sesID := range uSes.Sessions { 233 | if sesID != sessionID { 234 | sesIDs = append(sesIDs) 235 | } 236 | } 237 | s.userSessions.Replace(ctx, &uSes) 238 | return s.singleSessions.Delete(ctx, &sSes) 239 | } 240 | 241 | func (s *ServerlessSessionStorage) GetUserSessions(ctx context.Context, userID string) ([]SingleSessionData, error) { 242 | iter := s.singleSessions.Query().Where("user_id", "=", userID).Get(ctx) 243 | defer iter.Stop() 244 | now := currentTime(ctx) 245 | var result []SingleSessionData 246 | at := s.config.SessionAbsoluteTimeoutTerm 247 | it := s.config.SessionIdleTimeoutTerm 248 | for { 249 | var sSes SingleSessionData 250 | err := iter.Next(ctx, &sSes) 251 | if err == io.EOF { 252 | break 253 | } else if err != nil { 254 | return nil, err 255 | } else if now.Sub(sSes.LoginAt) < at && now.Sub(sSes.LastAccessAt) < it { 256 | result = append(result, sSes) 257 | } 258 | } 259 | return result, nil 260 | } 261 | 262 | func (s *ServerlessSessionStorage) FindBySessionToken(ctx context.Context, token string) (*Session, error) { 263 | sSes, uSes, status, err := s.readSession(ctx, token) 264 | if err != nil { 265 | return nil, err 266 | } 267 | 268 | if status == BeforeLogin { 269 | return &Session{ 270 | LoginAt: UnixTime(sSes.LoginAt), 271 | UserID: "", 272 | Data: sSes.LoginInfo, 273 | Status: status, 274 | }, nil 275 | } else { 276 | data := uSes.Data 277 | if data == nil { 278 | data = make(map[string]string) 279 | } 280 | return &Session{ 281 | LoginAt: UnixTime(sSes.LoginAt), 282 | ExpireAt: UnixTime(sSes.LoginAt.Add(s.config.SessionAbsoluteTimeoutTerm)), 283 | LastAccessAt: UnixTime(sSes.LastAccessAt), 284 | UserID: sSes.UserID, 285 | DisplayName: uSes.DisplayName, 286 | Email: uSes.Email, 287 | Organization: uSes.Organization, 288 | Scopes: uSes.Scopes, 289 | Status: status, 290 | Data: data, 291 | }, nil 292 | } 293 | } 294 | 295 | func (s *ServerlessSessionStorage) readSession(ctx context.Context, token string) (*SingleSessionData, *UserSession, SessionStatus, error) { 296 | sSes := SingleSessionData{ID: token} 297 | err := s.singleSessions.Get(ctx, &sSes) 298 | if err != nil { 299 | code := gcerrors.Code(err) 300 | if code == gcerrors.NotFound { 301 | return nil, nil, 0, ErrInvalidSessionToken 302 | } else { 303 | return nil, nil, 0, err 304 | } 305 | } 306 | 307 | now := currentTime(ctx) 308 | 309 | if sSes.UserID == "" { 310 | if now.Sub(sSes.LoginAt) > s.config.LoginTimeoutTerm { 311 | s.Logout(ctx, token) 312 | return nil, nil, 0, ErrInvalidSessionToken 313 | } 314 | return &sSes, nil, BeforeLogin, nil 315 | } 316 | uSes := UserSession{ID: sSes.UserID} 317 | err = s.userSessions.Get(ctx, &uSes) 318 | if err != nil { 319 | if gcerrors.Code(err) == gcerrors.NotFound { 320 | return nil, nil, 0, errors.New("invalid user id") 321 | } else { 322 | return nil, nil, 0, err 323 | } 324 | } 325 | var status SessionStatus 326 | if now.Sub(sSes.LoginAt) > s.config.SessionAbsoluteTimeoutTerm { 327 | s.Logout(ctx, token) 328 | return nil, nil, 0, ErrInvalidSessionToken 329 | } else if now.Sub(sSes.LastAccessAt) > s.config.SessionIdleTimeoutTerm { 330 | status = IdleTimeoutSession 331 | } else { 332 | status = ActiveSession 333 | } 334 | return &sSes, &uSes, status, nil 335 | } 336 | 337 | func (s ServerlessSessionStorage) UpdateSessionData(ctx context.Context, sessionID string, directives []*Directive) (err error) { 338 | sSes, uSes, _, err := s.readSession(ctx, sessionID) 339 | if err != nil { 340 | return err 341 | } 342 | if len(directives) > 0 { 343 | for _, d := range directives { 344 | if d.Value == "" { 345 | delete(uSes.Data, d.Key) 346 | } else { 347 | if uSes.Data == nil { 348 | uSes.Data = make(map[string]string) 349 | } 350 | uSes.Data[d.Key] = d.Value 351 | } 352 | } 353 | s.userSessions.Replace(ctx, uSes) 354 | } 355 | sSes.LastAccessAt = currentTime(ctx) 356 | return s.singleSessions.Replace(ctx, sSes) 357 | } 358 | 359 | func (s ServerlessSessionStorage) RenewSession(ctx context.Context, oldSessionID string) (newSessionID string, err error) { 360 | sSes := SingleSessionData{ID: oldSessionID} 361 | err = s.singleSessions.Get(ctx, &sSes) 362 | if err != nil { 363 | code := gcerrors.Code(err) 364 | if code == gcerrors.NotFound { 365 | return "", ErrInvalidSessionToken 366 | } else { 367 | return "", err 368 | } 369 | } 370 | now := currentTime(ctx) 371 | if now.Sub(sSes.LoginAt) > s.config.SessionAbsoluteTimeoutTerm { 372 | s.Logout(ctx, oldSessionID) 373 | return "", ErrInvalidSessionToken 374 | } else if now.Sub(sSes.LastAccessAt) > s.config.SessionIdleTimeoutTerm { 375 | newSessionID, err := s.generateNewSessionID(ctx) 376 | if err != nil { 377 | return "", err 378 | } 379 | sSes.ID = newSessionID 380 | err = s.singleSessions.Create(ctx, &sSes) 381 | if err != nil { 382 | return "", err 383 | } 384 | err = s.singleSessions.Delete(ctx, &SingleSessionData{ID: oldSessionID}) 385 | if err != nil { 386 | return "", err 387 | } 388 | return newSessionID, nil 389 | } else { 390 | return oldSessionID, nil 391 | } 392 | } 393 | 394 | var _ SessionStorage = &ServerlessSessionStorage{} 395 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func startSessionAndRedirect(c *Config, s SessionStorage, w http.ResponseWriter, r *http.Request) { 9 | sessionID, err := s.StartLogin(r.Context(), map[string]string{ 10 | "landingURL": r.RequestURI, 11 | }) 12 | log.Printf("🥚 start login: %s %s\n", sessionID, r.RequestURI) 13 | if err != nil { 14 | http.Error(w, "internal server error: "+err.Error(), http.StatusInternalServerError) 15 | return 16 | } 17 | setSessionID(r.Context(), w, sessionID, c, BeforeLogin) 18 | http.Redirect(w, r, "/.wru/login", http.StatusFound) 19 | return 20 | } 21 | 22 | func lookupSessionFromRequest(c *Config, s SessionStorage, r *http.Request) (string, *Session, bool) { 23 | var sessionID string 24 | for _, ck := range r.Cookies() { 25 | if ck.Name == c.ClientSessionKey { 26 | sessionID = ck.Value 27 | break 28 | } 29 | } 30 | if sessionID != "" { 31 | ses, err := s.FindBySessionToken(r.Context(), sessionID) 32 | if err == nil { 33 | return sessionID, ses, true 34 | } 35 | } 36 | return "", nil, false 37 | } 38 | -------------------------------------------------------------------------------- /sessionstorage.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/ymotongpoo/datemaki" 17 | ) 18 | 19 | var ErrInvalidSessionToken = errors.New("invalid session token") 20 | 21 | type SessionStatus int 22 | 23 | const ( 24 | BeforeLogin SessionStatus = iota 25 | ActiveSession 26 | IdleTimeoutSession 27 | AbsoluteTimeoutSession // This is not used 28 | ) 29 | 30 | type SingleSessionData struct { 31 | ID string `docstore:"id" json:"-"` 32 | UserID string `docstore:"user_id" json:"-"` 33 | LoginAt time.Time `docstore:"login_at" json:"login_at"` 34 | LastAccessAt time.Time `docstore:"last_access_at" json:"last_access_at"` 35 | CurrentSession bool `docstore:"-" json:"current"` 36 | 37 | LoginInfo map[string]string `docstore:"loginInfo" json:"login_info"` 38 | } 39 | 40 | func (s SingleSessionData) LoginAtFormat() string { 41 | return s.LoginAt.Format("2006/Jan/02 15:04") 42 | } 43 | 44 | func (s SingleSessionData) LoginAtForHuman() string { 45 | return datemaki.FormatDuration(time.Now().Sub(s.LoginAt)) 46 | } 47 | 48 | func (s SingleSessionData) LastAccessAtFormat() string { 49 | return s.LastAccessAt.Format("2006/Jan/02 15:04") 50 | } 51 | 52 | func (s SingleSessionData) LastAccessAtForHuman() string { 53 | return datemaki.FormatDuration(time.Now().Sub(s.LastAccessAt)) 54 | } 55 | 56 | func (s SingleSessionData) OS() string { 57 | return s.LoginInfo["os"] 58 | } 59 | 60 | func (s SingleSessionData) Browser() string { 61 | return s.LoginInfo["browser"] 62 | } 63 | 64 | func (s SingleSessionData) IdP() string { 65 | return s.LoginInfo["login-idp"] 66 | } 67 | 68 | func (s SingleSessionData) Location() string { 69 | return s.LoginInfo["country"] + "(" + s.LoginInfo["ip"] + ")" 70 | } 71 | 72 | type AllUserSessions []SingleSessionData 73 | 74 | func (as AllUserSessions) WriteAsJson(w io.Writer) error { 75 | type Sessions struct { 76 | Sessions []SingleSessionData `json:"sessions"` 77 | } 78 | 79 | e := json.NewEncoder(w) 80 | return e.Encode(&Sessions{ 81 | Sessions: as, 82 | }) 83 | } 84 | 85 | type UserSession struct { 86 | ID string `docstore:"id"` 87 | Sessions []string `docstore:"singleSessions"` 88 | Data map[string]string `docstore:"data"` 89 | 90 | // User Informations 91 | DisplayName string `docstore:"name"` 92 | Email string `docstore:"email"` 93 | Organization string `docstore:"org"` 94 | Scopes []string `docstore:"scopes"` 95 | } 96 | 97 | type UnixTime time.Time 98 | 99 | func (u UnixTime) MarshalJSON() ([]byte, error) { 100 | return strconv.AppendInt(nil, time.Time(u).UnixNano(), 10), nil 101 | } 102 | 103 | type Session struct { 104 | LoginAt UnixTime `json:"login_at"` 105 | ExpireAt UnixTime `json:"expire_at"` 106 | LastAccessAt UnixTime `json:"last_access_at"` 107 | UserID string `json:"id"` 108 | DisplayName string `json:"name"` 109 | Email string `json:"email"` 110 | Organization string `json:"org"` 111 | Scopes []string `json:"scopes"` 112 | Status SessionStatus `json:"-"` 113 | Data map[string]string `json:"data"` 114 | directrives []*Directive `json:"-"` 115 | } 116 | 117 | func (s *Session) AddSessionData(key, value string) { 118 | s.directrives = append(s.directrives, &Directive{Key: key, Value: value}) 119 | } 120 | 121 | func (s *Session) RemoveSessionData(key string) { 122 | s.directrives = append(s.directrives, &Directive{Key: key, Value: ""}) 123 | } 124 | 125 | type Directive struct { 126 | Key string 127 | Value string 128 | } 129 | 130 | var directiveRe = regexp.MustCompile(`\s*(\S+)\s*=\s*(.*)`) 131 | 132 | func parseDirective(src string) (*Directive, error) { 133 | match := directiveRe.FindStringSubmatch(src) 134 | if len(match) == 0 { 135 | return nil, fmt.Errorf("parse directive error: %s", src) 136 | } 137 | return &Directive{ 138 | Key: match[1], 139 | Value: match[2], 140 | }, nil 141 | } 142 | 143 | func getGeoLocation(c *Config, r *http.Request) (string, string) { 144 | var remoteAddr string 145 | f := r.Header.Get("Forwarded") 146 | if f == "" { 147 | f = r.Header.Get("X-Forwarded-For") 148 | } 149 | if f == "" { 150 | remoteAddr = r.RemoteAddr 151 | } else { 152 | ips := strings.Split(f, ",") 153 | remoteAddr = strings.TrimSpace(ips[0]) // todo: select proxy if needed 154 | } 155 | 156 | if c.geoIPDB == nil { 157 | return "No GeoIP DB", remoteAddr 158 | } 159 | ip := net.ParseIP(remoteAddr) 160 | record, err := c.geoIPDB.Country(ip) 161 | if err != nil { 162 | return "GeoIP request error", remoteAddr 163 | } 164 | return record.Country.Names["en"], remoteAddr 165 | } 166 | 167 | type SessionStorage interface { 168 | // StartLogin is called before login session 169 | // info keeps information like redirect URL 170 | StartLogin(ctx context.Context, info map[string]string) (sessionID string, err error) 171 | // AddLoginInfo adds extra login information for IDP. 172 | AddLoginInfo(ctx context.Context, oldSessionID string, info map[string]string) (newSessionID string, err error) 173 | // startSessionAndRedirect is called after authorization and it renews login session ID and return info that is stored in StartLogin 174 | StartSession(ctx context.Context, oldSessionID string, user *User, r *http.Request, newLoginInfo map[string]string) (newSessionID string, info map[string]string, err error) 175 | Logout(ctx context.Context, sessionID string) error 176 | GetUserSessions(ctx context.Context, userID string) ([]SingleSessionData, error) 177 | FindBySessionToken(ctx context.Context, sessionID string) (*Session, error) 178 | UpdateSessionData(ctx context.Context, sessionID string, directives []*Directive) (err error) 179 | RenewSession(ctx context.Context, oldSessionID string) (sessionID string, err error) 180 | } 181 | 182 | func NewSessionStorage(ctx context.Context, c *Config, out io.Writer) (SessionStorage, error) { 183 | // todo: switch redis 184 | if c.SessionStorage == "" { 185 | return NewMemorySessionStorage(ctx, c, "") 186 | } else { 187 | return NewServerlessSessionStorage(ctx, c, "") 188 | } 189 | } 190 | 191 | type RedisSessionStorage struct { 192 | } 193 | 194 | func (s RedisSessionStorage) UpdateSessionData(ctx context.Context, sessionID string, directives []*Directive) (err error) { 195 | panic("implement me") 196 | } 197 | 198 | func (s RedisSessionStorage) RenewSession(ctx context.Context, oldSessionID string) (sessionID string, err error) { 199 | panic("implement me") 200 | } 201 | 202 | func (s RedisSessionStorage) StartLogin(ctx context.Context, info map[string]string) (sessionID string, err error) { 203 | panic("implement me") 204 | } 205 | 206 | func (s RedisSessionStorage) AddLoginInfo(ctx context.Context, oldSessionID string, info map[string]string) (newSessionID string, err error) { 207 | panic("implement me") 208 | } 209 | 210 | func (s RedisSessionStorage) StartSession(ctx context.Context, oldSessionID string, user *User, r *http.Request, newLoginInfo map[string]string) (sessionID string, info map[string]string, err error) { 211 | panic("implement me") 212 | } 213 | 214 | func (s RedisSessionStorage) Logout(ctx context.Context, sessionID string) error { 215 | panic("implement me") 216 | } 217 | 218 | func (s RedisSessionStorage) GetUserSessions(ctx context.Context, userID string) ([]SingleSessionData, error) { 219 | panic("implement me") 220 | } 221 | 222 | func (s RedisSessionStorage) FindBySessionToken(ctx context.Context, token string) (*Session, error) { 223 | panic("implement me") 224 | } 225 | 226 | var _ SessionStorage = &RedisSessionStorage{} 227 | -------------------------------------------------------------------------------- /sessionstorage_test.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | _ "gocloud.dev/docstore/memdocstore" 6 | "reflect" 7 | "sort" 8 | "testing" 9 | "time" 10 | 11 | "github.com/rs/xid" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestFederatedLoginSuccess(t *testing.T) { 16 | s, err := NewMemorySessionStorage(context.Background(), defaultConfig(), xid.New().String()) 17 | assert.NotNil(t, s) 18 | assert.NoError(t, err) 19 | 20 | now := time.Date(2021, time.July, 2, 10, 0, 0, 0, time.Local) 21 | ctx := setFixTime(context.Background(), now) 22 | 23 | firstSessionID, err := s.StartLogin(ctx, map[string]string{ 24 | "redirectURL": "/", 25 | }) 26 | assert.NotEqual(t, "", firstSessionID) 27 | 28 | secondSessionID, err := s.AddLoginInfo(ctx, firstSessionID, map[string]string{ 29 | "provider": "twitter", 30 | }) 31 | assert.NoError(t, err) 32 | assert.NotEqual(t, firstSessionID, secondSessionID) 33 | 34 | ses, err := s.FindBySessionToken(ctx, secondSessionID) 35 | assert.NoError(t, err) 36 | assert.NotNil(t, ses) 37 | 38 | r := dummyRequest() 39 | user := dummyUser("user1") 40 | 41 | newSessionID, loginInfo, err := s.StartSession(ctx, secondSessionID, user, r, map[string]string{}) 42 | assert.NoError(t, err) 43 | assert.NotEqual(t, "", newSessionID) 44 | assert.NotEqual(t, firstSessionID, newSessionID) 45 | assert.NotEqual(t, secondSessionID, newSessionID) 46 | assert.Equal(t, "/", loginInfo["redirectURL"]) 47 | assert.Equal(t, "twitter", loginInfo["provider"]) 48 | } 49 | 50 | func TestDebugLoginSuccess(t *testing.T) { 51 | s, err := NewMemorySessionStorage(context.Background(), defaultConfig(), xid.New().String()) 52 | assert.NotNil(t, s) 53 | assert.NoError(t, err) 54 | 55 | now := time.Date(2021, time.July, 2, 10, 0, 0, 0, time.Local) 56 | ctx := setFixTime(context.Background(), now) 57 | 58 | oldSessionID, err := s.StartLogin(ctx, map[string]string{"redirectURL": "/"}) 59 | assert.NotEqual(t, "", oldSessionID) 60 | 61 | ses, err := s.FindBySessionToken(ctx, oldSessionID) 62 | assert.NoError(t, err) 63 | assert.NotNil(t, ses) 64 | 65 | r := dummyRequest() 66 | user := dummyUser("user1") 67 | 68 | newSessionID, loginInfo, err := s.StartSession(ctx, oldSessionID, user, r, map[string]string{}) 69 | assert.NoError(t, err) 70 | assert.NotEqual(t, "", newSessionID) 71 | assert.NotEqual(t, oldSessionID, newSessionID) 72 | assert.Equal(t, "/", loginInfo["redirectURL"]) 73 | } 74 | 75 | func TestLoginFail_NoStartLogin(t *testing.T) { 76 | s, err := NewMemorySessionStorage(context.Background(), defaultConfig(), xid.New().String()) 77 | assert.NotNil(t, s) 78 | assert.NoError(t, err) 79 | 80 | now := time.Date(2021, time.July, 2, 10, 0, 0, 0, time.Local) 81 | ctx := setFixTime(context.Background(), now) 82 | 83 | r := dummyRequest() 84 | user := dummyUser("user1") 85 | 86 | _, _, err = s.StartSession(ctx, "invalid-session", user, r, map[string]string{}) 87 | 88 | assert.Error(t, err) 89 | } 90 | 91 | func TestLoginFail_StartSessionTwice(t *testing.T) { 92 | s, err := NewMemorySessionStorage(context.Background(), defaultConfig(), xid.New().String()) 93 | assert.NotNil(t, s) 94 | assert.NoError(t, err) 95 | 96 | now := time.Date(2021, time.July, 2, 10, 0, 0, 0, time.Local) 97 | ctx := setFixTime(context.Background(), now) 98 | 99 | oldSessionID, err := s.StartLogin(ctx, map[string]string{"redirectURL": "/"}) 100 | assert.NotEqual(t, "", oldSessionID) 101 | 102 | r := dummyRequest() 103 | user := dummyUser("user1") 104 | 105 | _, _, err = s.StartSession(ctx, oldSessionID, user, r, map[string]string{}) 106 | assert.NoError(t, err) 107 | _, _, err = s.StartSession(ctx, oldSessionID, user, r, map[string]string{}) 108 | assert.Error(t, err) 109 | } 110 | 111 | func TestSessionStorage_SingleSession(t *testing.T) { 112 | ctx, s, sid, err := login(t, "user1") 113 | assert.NoError(t, err) 114 | defer s.Close() 115 | 116 | now := currentTime(ctx) 117 | 118 | ses, err := s.FindBySessionToken(ctx, sid) 119 | assert.NotNil(t, ses) 120 | assert.NoError(t, err) 121 | assert.Equal(t, "user1", ses.UserID) 122 | assert.Equal(t, now, time.Time(ses.LoginAt)) 123 | assert.Equal(t, ActiveSession, ses.Status) 124 | } 125 | 126 | func TestSessionStorage_SessionNotFound(t *testing.T) { 127 | ctx, s, sid, err := login(t, "user1") 128 | assert.NoError(t, err) 129 | defer s.Close() 130 | 131 | ses, err := s.FindBySessionToken(ctx, sid+"_not_found") 132 | assert.Nil(t, ses) 133 | assert.ErrorIs(t, err, ErrInvalidSessionToken) 134 | } 135 | 136 | func TestSessionStorage_Logout(t *testing.T) { 137 | ctx, s, sid, err := login(t, "user1") 138 | assert.NoError(t, err) 139 | defer s.Close() 140 | 141 | err = s.Logout(ctx, sid) 142 | assert.NoError(t, err) 143 | 144 | ses, err := s.FindBySessionToken(ctx, sid) 145 | assert.Nil(t, ses) 146 | assert.ErrorIs(t, err, ErrInvalidSessionToken) 147 | } 148 | 149 | func TestSessionStorage_SingleSession_Timeout(t *testing.T) { 150 | ctx, s, sid, err := login(t, "user1") 151 | assert.NoError(t, err) 152 | defer s.Close() 153 | 154 | now := currentTime(ctx) 155 | 156 | // Idle Timeout 157 | afterIdleTimeout := now.Add(time.Hour * 4) 158 | ctx2 := setFixTime(context.Background(), afterIdleTimeout) 159 | 160 | ses, err := s.FindBySessionToken(ctx2, sid) 161 | assert.NotNil(t, ses) 162 | assert.NoError(t, err) 163 | assert.Equal(t, "user1", ses.UserID) 164 | assert.Equal(t, IdleTimeoutSession, ses.Status) 165 | 166 | // Absolute timeout 167 | afterAbsoluteTimeout := now.Add(time.Hour * 40 * 24) 168 | ctx4 := setFixTime(context.Background(), afterAbsoluteTimeout) 169 | 170 | ses, err = s.FindBySessionToken(ctx4, sid) 171 | assert.Nil(t, ses) 172 | assert.ErrorIs(t, err, ErrInvalidSessionToken) 173 | } 174 | 175 | func TestSessionStorage_MultipleSession(t *testing.T) { 176 | // first login 177 | _, s, sid1, err := login(t, "user1") 178 | defer s.Close() 179 | 180 | assert.NoError(t, err) 181 | 182 | now := time.Date(2021, time.July, 2, 10, 0, 0, 0, time.Local) 183 | ctx := setFixTime(context.Background(), now) 184 | 185 | // same user login again from other browsers 186 | oldSessionID, err := s.StartLogin(ctx, map[string]string{"redirectURL": "/"}) 187 | assert.NotEqual(t, "", oldSessionID) 188 | 189 | r := dummyRequest() 190 | user := dummyUser("user1") 191 | 192 | sid2, _, err := s.StartSession(ctx, oldSessionID, user, r, map[string]string{}) 193 | assert.NoError(t, err) 194 | assert.NotEqual(t, sid1, sid2) 195 | 196 | sessions, err := s.GetUserSessions(ctx, "user1") 197 | 198 | actual := []string{sessions[0].ID, sessions[1].ID} 199 | expected := []string{sid1, sid2} 200 | sort.Strings(actual) 201 | sort.Strings(expected) 202 | 203 | assert.Len(t, sessions, 2) 204 | assert.Equal(t, expected, actual) 205 | } 206 | 207 | func Test_parseDirective(t *testing.T) { 208 | type args struct { 209 | src string 210 | } 211 | tests := []struct { 212 | name string 213 | args args 214 | want *Directive 215 | wantErr bool 216 | }{ 217 | { 218 | name: "simple assign", 219 | args: args{ 220 | src: "Wru-Set-Session-Data: key=value", 221 | }, 222 | want: &Directive{ 223 | Key: "key", 224 | Value: "value", 225 | }, 226 | }, 227 | { 228 | name: "simple remove", 229 | args: args{ 230 | src: "Wru-Set-Session-Data: key=", 231 | }, 232 | want: &Directive{ 233 | Key: "key", 234 | Value: "", 235 | }, 236 | }, 237 | } 238 | for _, tt := range tests { 239 | t.Run(tt.name, func(t *testing.T) { 240 | got, err := parseDirective(tt.args.src) 241 | if (err != nil) != tt.wantErr { 242 | t.Errorf("parseDirective() error = %v, wantErr %v", err, tt.wantErr) 243 | return 244 | } 245 | if !reflect.DeepEqual(got, tt.want) { 246 | t.Errorf("parseDirective() got = %v, want %v", got, tt.want) 247 | } 248 | }) 249 | } 250 | } 251 | 252 | func TestSessionStorage_UpdateSessionData(t *testing.T) { 253 | ctx, s, sid, err := login(t, "user1") 254 | defer s.Close() 255 | 256 | now := time.Date(2021, time.July, 2, 11, 30, 0, 0, time.Local) 257 | ctx = setFixTime(context.Background(), now) 258 | 259 | s.UpdateSessionData(ctx, sid, []*Directive{ 260 | { 261 | Key: "key", 262 | Value: "value", 263 | }, 264 | }) 265 | 266 | ses, err := s.FindBySessionToken(ctx, sid) 267 | assert.NotNil(t, ses) 268 | assert.NoError(t, err) 269 | assert.Equal(t, "user1", ses.UserID) 270 | assert.Equal(t, ActiveSession, ses.Status) 271 | assert.Equal(t, "value", ses.Data["key"]) 272 | 273 | // this is not expired because UpdateSessionData updates IdleTimeout 274 | now = time.Date(2021, time.July, 2, 13, 00, 0, 0, time.Local) 275 | ctx = setFixTime(context.Background(), now) 276 | 277 | ses, err = s.FindBySessionToken(ctx, sid) 278 | assert.NotNil(t, ses) 279 | assert.NoError(t, err) 280 | assert.Equal(t, "user1", ses.UserID) 281 | assert.Equal(t, ActiveSession, ses.Status) 282 | assert.Equal(t, "value", ses.Data["key"]) 283 | } 284 | 285 | func TestSessionStorage_RenewSession(t *testing.T) { 286 | ctx, s, sid, err := login(t, "user1") 287 | defer s.Close() 288 | 289 | // Sid is active 290 | now := time.Date(2021, time.July, 2, 10, 30, 0, 0, time.Local) 291 | ctx = setFixTime(context.Background(), now) 292 | sid2, err := s.RenewSession(ctx, sid) 293 | assert.NoError(t, err) 294 | assert.Equal(t, sid, sid2) 295 | 296 | // Between IdleTimeout and AbsoluteTimeout 297 | now = time.Date(2021, time.July, 10, 10, 30, 0, 0, time.Local) 298 | ctx = setFixTime(context.Background(), now) 299 | sid3, err := s.RenewSession(ctx, sid) 300 | assert.NoError(t, err) 301 | assert.NotEqual(t, sid, sid3) 302 | 303 | // Old sid is expired 304 | ses, err := s.FindBySessionToken(ctx, sid) 305 | assert.Nil(t, ses) 306 | assert.Error(t, err) 307 | 308 | // renewed sid is active 309 | ses2, err := s.FindBySessionToken(ctx, sid3) 310 | assert.NotNil(t, ses2) 311 | assert.NoError(t, err) 312 | } 313 | -------------------------------------------------------------------------------- /templates.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "io" 7 | "path/filepath" 8 | ) 9 | 10 | //go:embed templates/*.html 11 | var defaultTemplates embed.FS 12 | 13 | const ( 14 | LoginPageTemplate = "login.html" 15 | DebugLoginPageTemplate = "debug_login.html" 16 | UserStatusPageTemplate = "user_status.html" 17 | UserSessionsPageTemplate = "user_sessions.html" 18 | ) 19 | 20 | var pages *template.Template 21 | 22 | type loginPageContext struct { 23 | GitHub bool 24 | Twitter bool 25 | OIDC bool 26 | } 27 | 28 | type debugLoginPageContext struct { 29 | Users []*User 30 | } 31 | 32 | func initTemplate(c *Config, out io.Writer) error { 33 | var err error 34 | if c.HTMLTemplateFolder != "" { 35 | pages, err = template.ParseGlob(filepath.Join(c.HTMLTemplateFolder, "*")) 36 | } else { 37 | pages, err = template.ParseFS(defaultTemplates, "templates/*.html") 38 | } 39 | if err != nil { 40 | return err 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /templates/debug_login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login 6 | 93 | 95 | 96 | 97 |
98 |
Login as...
99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | {{range .Users}} 111 | 112 | 113 | 114 | 115 | 116 | {{end}} 117 | 118 |
IDEmailNameOrganizationScopes
{{ .Email }}{{ .DisplayName }}{{ .Organization }}{{ .ScopeString }}
119 |
120 | 121 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login 6 | 87 | 88 | 89 |
90 |
Login with...
91 | {{ if .GitHub }}GitHub{{ end }} 92 | {{ if .Twitter }}{{ end }} 93 | {{ if .OIDC }}OpenID Connect{{ end }} 94 |
95 | 96 | -------------------------------------------------------------------------------- /templates/user_sessions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login 6 | 113 | 115 | 116 | 117 |
118 |

Sessions

119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | {{range .}} 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | {{end}} 141 | 142 |
Login AtLast AccessOSBrowserCountryIdP
{{ .LoginAtFormat }}({{ .LoginAtForHuman }}){{ .LastAccessAtFormat }}({{ .LastAccessAtForHuman }}){{ .OS }}{{ .Browser }}{{ .Location }}{{ .IdP }}{{ if .CurrentSession }} Current session {{ else }}
{{ end }}
143 | Show user statusLogout 144 |
145 | 146 | -------------------------------------------------------------------------------- /templates/user_status.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login 6 | 88 | 89 | 90 |
91 |

User Status

92 |

User Information

93 |
94 |
95 |
UserID:
96 |
{{ .UserID }}
97 |
Display Name:
98 |
{{ .DisplayName }}
99 |
E-Mail:
100 |
{{ .Email }}
101 |
Organization:
102 |
{{ .Organization }}
103 |
Scopes:
104 |
{{ .ScopeString }}
105 |
106 |
107 |

Federated IDs

108 |
109 |
110 | {{- range .FederatedUserAccounts -}} 111 |
{{ .Service }}:
112 |
{{ .Account }}
113 | {{- end -}} 114 |
115 |
116 | Show user sessionsLogout 117 |
118 | 119 | -------------------------------------------------------------------------------- /testdata/launch_keycloak.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run -d --rm -p 18080:8080 --name keycloak \ 4 | -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin \ 5 | -e KEYCLOAK_IMPORT=/tmp/keycloak_wrusample_realm.json \ 6 | -v $(pwd)/keycloak_wrusample_realm.json:/tmp/keycloak_wrusample_realm.json \ 7 | wizzn/keycloak:14 # jboss/keycloak if you don't use M1 mac 8 | -------------------------------------------------------------------------------- /testdata/testserver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "text/template" 11 | "time" 12 | ) 13 | 14 | var htmlTemplate = template.Must(template.New("html").Parse(` 15 | 16 | 17 | 18 | 19 | {{.RequestURL}} 20 | 21 | 22 |

WRU sample backend server

23 |

Login Status

24 |

RequestURL: {{.RequestURL}} 25 |

UserID: {{.UserID}}

26 |

Last Access At: {{.LastAccessAt.Format "2006/1/2 15:04:05"}}

27 |

Login At: {{.LoginAt.Format "2006/1/2 15:04:05"}}

28 |

Session Storage Sample

29 |

Access Count: {{.AccessCount}}

30 |

Links

31 |

Logout

32 |

User Status

33 |

Session Information

34 | 35 | 36 | `)) 37 | 38 | type PageContext struct { 39 | RequestURL string 40 | UserID string 41 | LastAccessAt time.Time 42 | LoginAt time.Time 43 | AccessCount int 44 | } 45 | 46 | type Session struct { 47 | LoginAt int64 `json:"login_at"` 48 | ExpireAt int64 `json:"expire_at"` 49 | LastAccessAt int64 `json:"last_access_at"` 50 | UserID string `json:"id"` 51 | DisplayName string `json:"name"` 52 | Email string `json:"email"` 53 | Organization string `json:"org"` 54 | Scopes []string `json:"scopes"` 55 | Data map[string]string `json:"data"` 56 | } 57 | 58 | func main() { 59 | fmt.Println("test server is running at :8080") 60 | fmt.Println("Run wru with the following env var:") 61 | fmt.Println(` WRU_FORWARD_TO: "/ => http://localhost:8080"`) 62 | 63 | var headerKey string 64 | if key, ok := os.LookupEnv("WRU_SERVER_SESSION_FIELD"); ok { 65 | headerKey = key 66 | } else { 67 | headerKey = "Wru-Session" 68 | } 69 | fmt.Printf("This test server checks %s header field\n", headerKey) 70 | 71 | http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 | fmt.Println("🪓 ", r.RequestURI) 73 | 74 | c := &PageContext{ 75 | RequestURL: r.RequestURI, 76 | } 77 | 78 | h := r.Header.Get(headerKey) 79 | if h != "" { 80 | var s Session 81 | json.NewDecoder(strings.NewReader(h)).Decode(&s) 82 | c.UserID = s.UserID 83 | c.LoginAt = time.Unix(0, s.LoginAt) 84 | c.LastAccessAt = time.Unix(0, s.LastAccessAt) 85 | count, err := strconv.ParseInt(s.Data["access-count"], 10, 64) 86 | if err == nil { 87 | c.AccessCount = int(count) 88 | } 89 | } 90 | if r.Method == "POST" && r.RequestURI == "/increment" { 91 | w.Header().Set("Wru-Set-Session-Data", fmt.Sprintf("access-count=%d", c.AccessCount + 1)) 92 | } 93 | htmlTemplate.Execute(w, c) 94 | })) 95 | } 96 | -------------------------------------------------------------------------------- /testdata/testuser.csv: -------------------------------------------------------------------------------- 1 | id,name,mail,org,scopes,twitter,github,oidc 2 | user1,test user1,testuser1@example.com,R&D,"admin,user,org:rd",testuser1,testuser1,testuser01@example.com 3 | user2,test user2,testuser2@example.com,R&D,"admin,user,org:rd",testuser2,testuser2,testuser02@example.com 4 | user2,test user3,testuser3@example.com,R&D,"admin,user,org:rd",shibu_jp,shibukawa,testuser03@example.com 5 | -------------------------------------------------------------------------------- /testdata/testuser_for_ut.csv: -------------------------------------------------------------------------------- 1 | id,name,mail,org,scopes,twitter,github 2 | testuser1,test user1,testuser1@example.com,R&D,"admin,user,org:rd",testuser1,testuser1 -------------------------------------------------------------------------------- /testdata/write_csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | with open('testuser.csv', 'w', newline='') as file: 3 | writer = csv.writer(file) 4 | writer.writerow(["id", "name", "mail", "org", "scopes", "twitter", "github", "oidc"]) 5 | writer.writerow(["user1", "test user1", "testuser1@example.com", "R&D", "admin,user,org:rd", "testuser1", "testuser1", "testuser01@example.com"]) 6 | writer.writerow(["user2", "test user2", "testuser2@example.com", "R&D", "admin,user,org:rd", "testuser2", "testuser2", "testuser02@example.com"]) 7 | writer.writerow(["user2", "test user3", "testuser3@example.com", "R&D", "admin,user,org:rd", "shibu_jp", "shibukawa", "testuser03@example.com"]) 8 | -------------------------------------------------------------------------------- /testhelper.go: -------------------------------------------------------------------------------- 1 | package wru 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/rs/xid" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type contextTimeKey string 15 | 16 | const timeKey contextTimeKey = "timeKey" 17 | 18 | func currentTime(ctx context.Context) time.Time { 19 | v := ctx.Value(timeKey) 20 | if t, ok := v.(time.Time); ok { 21 | return t 22 | } 23 | return time.Now() 24 | } 25 | 26 | func setFixTime(ctx context.Context, t time.Time) context.Context { 27 | return context.WithValue(ctx, timeKey, t) 28 | } 29 | 30 | func defaultConfig() *Config { 31 | return &Config{ 32 | SessionIdleTimeoutTerm: 3 * time.Hour, 33 | SessionAbsoluteTimeoutTerm: 30 * 24 * time.Hour, 34 | } 35 | } 36 | 37 | func login(t *testing.T, userID string) (context.Context, *ServerlessSessionStorage, string, error) { 38 | t.Helper() 39 | 40 | s, err := NewMemorySessionStorage(context.Background(), defaultConfig(), xid.New().String()) 41 | assert.NotNil(t, s) 42 | assert.NoError(t, err) 43 | 44 | now := time.Date(2021, time.July, 2, 10, 0, 0, 0, time.Local) 45 | ctx := setFixTime(context.Background(), now) 46 | 47 | oldSessionID, err := s.StartLogin(ctx, map[string]string{}) 48 | 49 | r := httptest.NewRequest("GET", "/test", nil) 50 | r.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36") 51 | 52 | user := &User{ 53 | DisplayName: userID, 54 | Organization: "secret", 55 | UserID: userID, 56 | Email: userID + "@example.com", 57 | Scopes: []string{"login"}, 58 | } 59 | 60 | loginInfo := map[string]string{"login-idp": "debug"} 61 | sid, _, err := s.StartSession(ctx, oldSessionID, user, r, loginInfo) 62 | assert.NoError(t, err) 63 | assert.NotEqual(t, "", sid) 64 | return ctx, s, sid, err 65 | } 66 | 67 | func dummyUser(userID string) *User { 68 | user := &User{ 69 | DisplayName: userID, 70 | Organization: "secret", 71 | UserID: userID, 72 | Email: userID + "@example.com", 73 | Scopes: []string{"login"}, 74 | } 75 | return user 76 | } 77 | 78 | func dummyRequest() *http.Request { 79 | r := httptest.NewRequest("GET", "/test", nil) 80 | r.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36") 81 | return r 82 | } 83 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package wru 4 | 5 | //go:generate gocredits -skip-missing -w --------------------------------------------------------------------------------