blob: aefbd1aaeadb80d5116b4681eaae81ed85cda960 [file] [log] [blame]
Serge Bazanski1572e522020-12-03 23:19:28 +01001package main
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "flag"
9 "fmt"
10 "html/template"
11 "io/ioutil"
12 "net/http"
13 "sync"
14 "time"
15
16 "github.com/boltdb/bolt"
17 oidc "github.com/coreos/go-oidc"
18 "github.com/golang/glog"
19 "github.com/gorilla/sessions"
20 "golang.org/x/oauth2"
21)
22
23var (
24 flagSOAPAddress string
25 flagSOAPUsername string
26 flagSOAPPassword string
27 flagListen string
28 flagSecret string
29 flagOAuthClientID string
30 flagOAuthClientSecret string
31 flagOAuthRedirectURL string
32 flagDB string
33 flagMOTD string
34)
35
36func init() {
37 flag.Set("logtostderr", "true")
38}
39
40func main() {
41 flag.StringVar(&flagSOAPAddress, "soap_address", "http://127.0.0.1:7878", "Address of AC SOAP server")
42 flag.StringVar(&flagSOAPUsername, "soap_username", "test1", "SOAP username")
43 flag.StringVar(&flagSOAPPassword, "soap_password", "", "SOAP password")
44 flag.StringVar(&flagListen, "listen", "127.0.0.1:8080", "HTTP listen address")
45 flag.StringVar(&flagSecret, "secret", "", "Cookie secret")
46 flag.StringVar(&flagOAuthClientID, "oauth_client_id", "", "OAuth client ID")
47 flag.StringVar(&flagOAuthClientSecret, "oauth_client_secret", "", "OAuth client secret")
48 flag.StringVar(&flagOAuthRedirectURL, "oauth_redirect_url", "", "OAuth redirect URL")
49 flag.StringVar(&flagDB, "db", "db.db", "Path to database")
50 flag.StringVar(&flagMOTD, "motd", "", "Path to MOTD")
51 flag.Parse()
52
53 if flagSecret == "" {
54 glog.Exitf("-secret must be set")
55 }
56
57 var err error
58 var motd []byte
59 if flagMOTD == "" {
60 glog.Warningf("no MOTD defined, set -motd to get one")
61 } else {
62 motd, err = ioutil.ReadFile(flagMOTD)
63 if err != nil {
64 glog.Exitf("cannot read MOTD %q: %v", flagMOTD, err)
65 }
66 }
67
68 db, err := bolt.Open(flagDB, 0600, nil)
69 if err != nil {
70 glog.Exitf("opening database failed: %v", err)
71 }
72
73 provider, err := oidc.NewProvider(context.Background(), "https://sso.hackerspace.pl")
74 if err != nil {
75 glog.Exitf("newprovider: %v", err)
76 }
77 oauth2Config := oauth2.Config{
78 ClientID: flagOAuthClientID,
79 ClientSecret: flagOAuthClientSecret,
80 RedirectURL: flagOAuthRedirectURL,
81
82 // Discovery returns the OAuth2 endpoints.
83 Endpoint: provider.Endpoint(),
84
85 // "openid" is a required scope for OpenID Connect flows.
86 Scopes: []string{oidc.ScopeOpenID, "profile:read"},
87 }
88
89 err = db.Update(func(tx *bolt.Tx) error {
90 for _, name := range []string{
91 "emailToAccount",
92 } {
93 _, err := tx.CreateBucketIfNotExists([]byte(name))
94 if err != nil {
95 return fmt.Errorf("create bucket %q: %s", name, err)
96 }
97 }
98 return nil
99 })
100 if err != nil {
101 glog.Exitf("db setup failed: %v", err)
102 }
103
104 s := &server{
105 db: db,
106 store: sessions.NewCookieStore([]byte(flagSecret)),
107 oauth2: &oauth2Config,
108 motd: string(motd),
109 }
110
111 http.HandleFunc("/", s.viewIndex)
112 http.HandleFunc("/login", s.viewLogin)
113 http.HandleFunc("/oauth", s.viewOauth)
114 http.HandleFunc("/callback", s.viewOauthCallback)
115 http.HandleFunc("/setup", s.viewOauthSetup)
116 http.HandleFunc("/reset", s.viewReset)
117 http.HandleFunc("/logout", s.viewLogout)
Serge Bazanski7ea8e472020-12-04 10:48:37 +0100118 http.HandleFunc("/spaceapi", s.viewSpaceAPI)
Serge Bazanski1572e522020-12-03 23:19:28 +0100119
120 err = http.ListenAndServe(flagListen, nil)
121 if err != nil {
122 glog.Exitf("ListenAndServe: %v", err)
123 }
124}
125
126type server struct {
127 db *bolt.DB
128 store *sessions.CookieStore
129 oauth2 *oauth2.Config
130 motd string
131
132 onlineLock sync.RWMutex
133 onlineData []playerinfo
134 onlineDeadline time.Time
135}
136
137var (
138 tLogin = template.Must(template.New("login").Parse(`<html>
139<title>super wow - who are you?</title>
140<body>
141<pre>
142
143 ___ _ _ _ __ ___ _ __ __ _______ __
144/ __| | | | '_ \ / _ \ '__| \ \ /\ / / _ \ \ /\ / /
145\__ \ |_| | |_) | __/ | \ V V / (_) \ V V /
146|___/\__,_| .__/ \___|_| \_/\_/ \___/ \_/\_/
147 |_|
148 _ _ _ _ _ _
149| | _____ | | ___ (_)___ _ _ __ | | _____ | |_ _| |__
150| |/ / _ \| |/ _ \_ / _' | '_ \| |/ / _ \ | | | | | '_ \
151| < (_) | | __// / (_| | | | | < (_) | | | |_| | |_) |
152|_|\_\___/|_|\___/___\__,_|_| |_|_|\_\___/ |_|\__,_|_.__/
153
154 _ _ __
155| | _____ | | ___ __ _ ___ \ \
156| |/ / _ \| |/ _ \/ _' |/ _ \ (_) |
157| < (_) | | __/ (_| | (_) | _| |
158|_|\_\___/|_|\___|\__, |\___/ (_) |
159 |___/ /_/
160
161</pre>
162<a href="/oauth">Sign in (or create new account) with HSWAW SSO</a>.<br/>
163
164<p>
165Not a hswaw member? Talk to q3k.
166</p>
167</body>`))
168 tSetup = template.Must(template.New("setup").Parse(`<html>
169<title>super wow - setup account</title>
170<body>
171<b>hi, please provide details for your new WoW account</b><br/>
172pick any username you want, pick a 3-16 character password that isn't the same as your sso password (duh)<br />
173(this isn't your character name, this will only be used to log into WoW)
174{{ if .Error }}
175<br /><b>error</b>: {{ .Error }}<br />
176{{ end }}
177<form method="post" action="/setup">
178username:<input name="username" value={{ .Username }}></input><br/>
179password:<input name="password" type="password"></input><br/>
180<input type=submit value="create account"/>
181</form>
182</body>`))
183 tIndex = template.Must(template.New("index").Parse(`<html>
184<title>super wow</title>
185<style type="text/css">
186 body {
187 background-color: #fff;
188 }
189 table, th, td {
190 background-color: #eee;
191 padding: 0.2em 0.4em 0.2em 0.4em;
192 }
193 table th {
194 background-color: #c0c0c0;
195 }
196 table {
197 background-color: #fff;
198 border-spacing: 0.2em;
199 }
200</style>
201<body>
202<b>Hello, {{ .Username }}.</b></br>
203<a href="/logout">Log out.</a><br />
204{{ .MOTD }}
205<p>
206Your account name is {{ .Username }}. Use the password that you entered when logging in via SSO for the first time, or <form action="/reset" method="POST"><input name="password" type="password" /><input type="submit" value="set a new password"/>.</form>
207</p>
208<b>Currently in-game:</b>
209<table>
210<tr><th>Account</th><th>Character</th></tr>
211{{ range .Online }}
212<tr><td>{{ .Account }}</td><td>{{ .Character }}</td></tr>
213{{ end }}
214</table>
215</body>`))
216)
217
218func (s *server) session(r *http.Request) *sessions.Session {
219 session, _ := s.store.Get(r, sessionName)
220 return session
221}
222
223func (s *server) sessionGet(r *http.Request, k string) string {
224 v, ok := s.session(r).Values[k]
225 if !ok {
226 return ""
227 }
228 v2, ok := v.(string)
229 if !ok {
230 return ""
231 }
232 return v2
233}
234
235func (s *server) sessionPut(w http.ResponseWriter, r *http.Request, k, v string) {
236 session := s.session(r)
237 session.Values[k] = v
238 session.Save(r, w)
239}
240
241func (s *server) online(ctx context.Context) []playerinfo {
242 s.onlineLock.RLock()
243 if s.onlineData == nil || time.Now().After(s.onlineDeadline) {
244 s.onlineLock.RUnlock()
245 s.onlineLock.Lock()
246 data, err := onlinelist(ctx)
247 if err != nil {
248 glog.Errorf("onlinelist fatch failed: %v", err)
249 s.onlineDeadline = time.Now().Add(10 * time.Second)
250 } else {
251 s.onlineData = data
252 s.onlineDeadline = time.Now().Add(60 * time.Second)
253 }
254 s.onlineLock.Unlock()
255 s.onlineLock.RLock()
256 }
257
258 res := make([]playerinfo, len(s.onlineData))
259 for i, pi := range s.onlineData {
260 res[i] = pi
261 }
262 s.onlineLock.RUnlock()
263 return res
264}
265
266func (s *server) viewIndex(w http.ResponseWriter, r *http.Request) {
267 account := s.sessionGet(r, "account")
268 if account == "" {
269 http.Redirect(w, r, "/login", 302)
270 return
271 }
272 err := tIndex.Execute(w, map[string]interface{}{
273 "Username": account,
274 "Online": s.online(r.Context()),
275 "MOTD": template.HTML(s.motd),
276 })
277 if err != nil {
278 glog.Errorf("/: %v", err)
279 return
280 }
281}
282
283const sessionName = "wow"
284
285func (s *server) viewLogin(w http.ResponseWriter, r *http.Request) {
286 account := s.sessionGet(r, "account")
287 if account != "" {
288 http.Redirect(w, r, "/", 302)
289 return
290 }
291 err := tLogin.Execute(w, nil)
292 if err != nil {
293 glog.Errorf("/login: %v", err)
294 return
295 }
296}
297
298func (s *server) viewOauth(w http.ResponseWriter, r *http.Request) {
299 stateBytes := make([]byte, 8)
300 _, err := rand.Read(stateBytes)
301 if err != nil {
302 glog.Errorf("/oauth: random: %v", err)
303 return
304 }
305 state := hex.EncodeToString(stateBytes)
306 s.sessionPut(w, r, "ostate", state)
307 url := s.oauth2.AuthCodeURL(state)
308 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
309}
310
311func (s *server) viewOauthCallback(w http.ResponseWriter, r *http.Request) {
312 if r.FormValue("errors") != "" {
313 fmt.Fprintf(w, "Errors: %s", r.FormValue("errors"))
314 return
315 }
316 state := s.sessionGet(r, "ostate")
317 if state == "" {
318 glog.Errorf("No state")
319 http.Redirect(w, r, "/", 302)
320 return
321 }
322 if state != r.FormValue("state") {
323 glog.Errorf("Invalid state")
324 http.Redirect(w, r, "/", 302)
325 return
326 }
327 oauth2Token, err := s.oauth2.Exchange(r.Context(), r.FormValue("code"))
328 if err != nil {
329 glog.Errorf("Exchange failed: %v", err)
330 http.Redirect(w, r, "/", 302)
331 return
332 }
333 client := s.oauth2.Client(r.Context(), oauth2Token)
334 res, err := client.Get("https://sso.hackerspace.pl/api/1/userinfo")
335 if err != nil {
336 glog.Errorf("Userinfo failed: %v", err)
337 http.Redirect(w, r, "/", 302)
338 return
339 }
340 defer res.Body.Close()
341 data, _ := ioutil.ReadAll(res.Body)
342
343 ui := userinfo{}
344 err = json.Unmarshal(data, &ui)
345 if err != nil || ui.Email == "" {
346 glog.Errorf("Userinfo unarshal failed: %v", err)
347 http.Redirect(w, r, "/", 302)
348 return
349 }
350
351 account, err := s.accountForEmail(ui.Email)
352 if err != nil {
353 glog.Errorf("account get failed: %v", err)
354 http.Redirect(w, r, "/", 302)
355 return
356 }
357 if account != "" {
358 s.sessionPut(w, r, "account", account)
359 http.Redirect(w, r, "/", 302)
360 } else {
361 s.sessionPut(w, r, "email", ui.Email)
362 http.Redirect(w, r, "/setup", 302)
363 }
364}
365
366func (s *server) viewOauthSetup(w http.ResponseWriter, r *http.Request) {
367 email := s.sessionGet(r, "email")
368 if email == "" {
369 glog.Errorf("No email")
370 http.Redirect(w, r, "/", 302)
371 return
372 }
373
374 if r.Method == "GET" {
375 tSetup.Execute(w, nil)
376 return
377 }
378
379 username := r.FormValue("username")
380 password := r.FormValue("password")
381 if !reAccount.MatchString(username) {
382 tSetup.Execute(w, map[string]string{
383 "Username": username,
384 "Error": "Invalid username - must be 3-16 a-z 0-9 - _",
385 })
386 return
387 }
388 if !rePassword.MatchString(password) {
389 tSetup.Execute(w, map[string]string{
390 "Username": username,
391 "Error": "Invalid password - must be 3-16 a-z A-Z 0-9 - _",
392 })
393 return
394 }
395
396 // this races. ugh. no way to list users. yolo.
397 err := createAccount(r.Context(), username, password)
398 if err != nil {
399 tSetup.Execute(w, map[string]string{
400 "Username": username,
401 "Error": "Account already exists, pick a different username",
402 })
403 return
404 }
405
406 err = s.db.Update(func(tx *bolt.Tx) error {
407 b := tx.Bucket([]byte("emailToAccount"))
408 v := string(b.Get([]byte(email)))
409 if v != "" {
410 s.sessionPut(w, r, "account", v)
411 http.Redirect(w, r, "/", 302)
412 return nil
413 }
414 b.Put([]byte(email), []byte(username))
415 s.sessionPut(w, r, "account", username)
416 http.Redirect(w, r, "/", 302)
417 return nil
418 })
419 if err != nil {
420 glog.Errorf("setup tx: %v", err)
421 http.Redirect(w, r, "/", 302)
422 }
423}
424func (s *server) viewReset(w http.ResponseWriter, r *http.Request) {
425 account := s.sessionGet(r, "account")
426 if account == "" {
427 glog.Errorf("No account")
428 http.Redirect(w, r, "/", 302)
429 return
430 }
431
432 password := r.FormValue("password")
433 if !rePassword.MatchString(password) {
434 fmt.Fprintf(w, "pick a 3-16 password with not too many special chars")
435 return
436 }
437
438 // this races. ugh. no way to list users. yolo.
439 err := ensureAccount(r.Context(), account, password)
440 if err != nil {
441 glog.Errorf("ensureAccount(%q, _): %v", account, err)
442 fmt.Fprintf(w, "something went wrong, lol")
443 return
444 }
445 fmt.Fprintf(w, "new password set.")
446}
447
448func (s *server) viewLogout(w http.ResponseWriter, r *http.Request) {
449 s.sessionPut(w, r, "account", "")
450 s.sessionPut(w, r, "email", "")
451 http.Redirect(w, r, "/", 302)
452}
453
454func (s *server) accountForEmail(email string) (string, error) {
455 res := ""
456 err := s.db.View(func(tx *bolt.Tx) error {
457 b := tx.Bucket([]byte("emailToAccount"))
458 v := b.Get([]byte(email))
459 res = string(v)
460 return nil
461 })
462 return res, err
463}
464
465type userinfo struct {
466 Email string `json:"email"`
467}