├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | ID |
103 | Email |
104 | Name |
105 | Organization |
106 | Scopes |
107 |
108 |
109 |
110 | {{range .Users}}
111 | |
112 | {{ .Email }} |
113 | {{ .DisplayName }} |
114 | {{ .Organization }} |
115 | {{ .ScopeString }} |
116 |
{{end}}
117 |
118 |
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 | Login At |
123 | Last Access |
124 | OS |
125 | Browser |
126 | Country |
127 | IdP |
128 | |
129 |
130 |
131 |
132 | {{range .}}
133 | {{ .LoginAtFormat }}({{ .LoginAtForHuman }}) |
134 | {{ .LastAccessAtFormat }}({{ .LastAccessAtForHuman }}) |
135 | {{ .OS }} |
136 | {{ .Browser }} |
137 | {{ .Location }} |
138 | {{ .IdP }} |
139 | {{ if .CurrentSession }} Current session {{ else }}{{ end }} |
140 |
{{end}}
141 |
142 |
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
--------------------------------------------------------------------------------