blob: cd1f586fd8dc2dfd7e43dcabda49208fd8fcf4b2 [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)
118
119 err = http.ListenAndServe(flagListen, nil)
120 if err != nil {
121 glog.Exitf("ListenAndServe: %v", err)
122 }
123}
124
125type server struct {
126 db *bolt.DB
127 store *sessions.CookieStore
128 oauth2 *oauth2.Config
129 motd string
130
131 onlineLock sync.RWMutex
132 onlineData []playerinfo
133 onlineDeadline time.Time
134}
135
136var (
137 tLogin = template.Must(template.New("login").Parse(`<html>
138<title>super wow - who are you?</title>
139<body>
140<pre>
141
142 ___ _ _ _ __ ___ _ __ __ _______ __
143/ __| | | | '_ \ / _ \ '__| \ \ /\ / / _ \ \ /\ / /
144\__ \ |_| | |_) | __/ | \ V V / (_) \ V V /
145|___/\__,_| .__/ \___|_| \_/\_/ \___/ \_/\_/
146 |_|
147 _ _ _ _ _ _
148| | _____ | | ___ (_)___ _ _ __ | | _____ | |_ _| |__
149| |/ / _ \| |/ _ \_ / _' | '_ \| |/ / _ \ | | | | | '_ \
150| < (_) | | __// / (_| | | | | < (_) | | | |_| | |_) |
151|_|\_\___/|_|\___/___\__,_|_| |_|_|\_\___/ |_|\__,_|_.__/
152
153 _ _ __
154| | _____ | | ___ __ _ ___ \ \
155| |/ / _ \| |/ _ \/ _' |/ _ \ (_) |
156| < (_) | | __/ (_| | (_) | _| |
157|_|\_\___/|_|\___|\__, |\___/ (_) |
158 |___/ /_/
159
160</pre>
161<a href="/oauth">Sign in (or create new account) with HSWAW SSO</a>.<br/>
162
163<p>
164Not a hswaw member? Talk to q3k.
165</p>
166</body>`))
167 tSetup = template.Must(template.New("setup").Parse(`<html>
168<title>super wow - setup account</title>
169<body>
170<b>hi, please provide details for your new WoW account</b><br/>
171pick any username you want, pick a 3-16 character password that isn't the same as your sso password (duh)<br />
172(this isn't your character name, this will only be used to log into WoW)
173{{ if .Error }}
174<br /><b>error</b>: {{ .Error }}<br />
175{{ end }}
176<form method="post" action="/setup">
177username:<input name="username" value={{ .Username }}></input><br/>
178password:<input name="password" type="password"></input><br/>
179<input type=submit value="create account"/>
180</form>
181</body>`))
182 tIndex = template.Must(template.New("index").Parse(`<html>
183<title>super wow</title>
184<style type="text/css">
185 body {
186 background-color: #fff;
187 }
188 table, th, td {
189 background-color: #eee;
190 padding: 0.2em 0.4em 0.2em 0.4em;
191 }
192 table th {
193 background-color: #c0c0c0;
194 }
195 table {
196 background-color: #fff;
197 border-spacing: 0.2em;
198 }
199</style>
200<body>
201<b>Hello, {{ .Username }}.</b></br>
202<a href="/logout">Log out.</a><br />
203{{ .MOTD }}
204<p>
205Your 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>
206</p>
207<b>Currently in-game:</b>
208<table>
209<tr><th>Account</th><th>Character</th></tr>
210{{ range .Online }}
211<tr><td>{{ .Account }}</td><td>{{ .Character }}</td></tr>
212{{ end }}
213</table>
214</body>`))
215)
216
217func (s *server) session(r *http.Request) *sessions.Session {
218 session, _ := s.store.Get(r, sessionName)
219 return session
220}
221
222func (s *server) sessionGet(r *http.Request, k string) string {
223 v, ok := s.session(r).Values[k]
224 if !ok {
225 return ""
226 }
227 v2, ok := v.(string)
228 if !ok {
229 return ""
230 }
231 return v2
232}
233
234func (s *server) sessionPut(w http.ResponseWriter, r *http.Request, k, v string) {
235 session := s.session(r)
236 session.Values[k] = v
237 session.Save(r, w)
238}
239
240func (s *server) online(ctx context.Context) []playerinfo {
241 s.onlineLock.RLock()
242 if s.onlineData == nil || time.Now().After(s.onlineDeadline) {
243 s.onlineLock.RUnlock()
244 s.onlineLock.Lock()
245 data, err := onlinelist(ctx)
246 if err != nil {
247 glog.Errorf("onlinelist fatch failed: %v", err)
248 s.onlineDeadline = time.Now().Add(10 * time.Second)
249 } else {
250 s.onlineData = data
251 s.onlineDeadline = time.Now().Add(60 * time.Second)
252 }
253 s.onlineLock.Unlock()
254 s.onlineLock.RLock()
255 }
256
257 res := make([]playerinfo, len(s.onlineData))
258 for i, pi := range s.onlineData {
259 res[i] = pi
260 }
261 s.onlineLock.RUnlock()
262 return res
263}
264
265func (s *server) viewIndex(w http.ResponseWriter, r *http.Request) {
266 account := s.sessionGet(r, "account")
267 if account == "" {
268 http.Redirect(w, r, "/login", 302)
269 return
270 }
271 err := tIndex.Execute(w, map[string]interface{}{
272 "Username": account,
273 "Online": s.online(r.Context()),
274 "MOTD": template.HTML(s.motd),
275 })
276 if err != nil {
277 glog.Errorf("/: %v", err)
278 return
279 }
280}
281
282const sessionName = "wow"
283
284func (s *server) viewLogin(w http.ResponseWriter, r *http.Request) {
285 account := s.sessionGet(r, "account")
286 if account != "" {
287 http.Redirect(w, r, "/", 302)
288 return
289 }
290 err := tLogin.Execute(w, nil)
291 if err != nil {
292 glog.Errorf("/login: %v", err)
293 return
294 }
295}
296
297func (s *server) viewOauth(w http.ResponseWriter, r *http.Request) {
298 stateBytes := make([]byte, 8)
299 _, err := rand.Read(stateBytes)
300 if err != nil {
301 glog.Errorf("/oauth: random: %v", err)
302 return
303 }
304 state := hex.EncodeToString(stateBytes)
305 s.sessionPut(w, r, "ostate", state)
306 url := s.oauth2.AuthCodeURL(state)
307 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
308}
309
310func (s *server) viewOauthCallback(w http.ResponseWriter, r *http.Request) {
311 if r.FormValue("errors") != "" {
312 fmt.Fprintf(w, "Errors: %s", r.FormValue("errors"))
313 return
314 }
315 state := s.sessionGet(r, "ostate")
316 if state == "" {
317 glog.Errorf("No state")
318 http.Redirect(w, r, "/", 302)
319 return
320 }
321 if state != r.FormValue("state") {
322 glog.Errorf("Invalid state")
323 http.Redirect(w, r, "/", 302)
324 return
325 }
326 oauth2Token, err := s.oauth2.Exchange(r.Context(), r.FormValue("code"))
327 if err != nil {
328 glog.Errorf("Exchange failed: %v", err)
329 http.Redirect(w, r, "/", 302)
330 return
331 }
332 client := s.oauth2.Client(r.Context(), oauth2Token)
333 res, err := client.Get("https://sso.hackerspace.pl/api/1/userinfo")
334 if err != nil {
335 glog.Errorf("Userinfo failed: %v", err)
336 http.Redirect(w, r, "/", 302)
337 return
338 }
339 defer res.Body.Close()
340 data, _ := ioutil.ReadAll(res.Body)
341
342 ui := userinfo{}
343 err = json.Unmarshal(data, &ui)
344 if err != nil || ui.Email == "" {
345 glog.Errorf("Userinfo unarshal failed: %v", err)
346 http.Redirect(w, r, "/", 302)
347 return
348 }
349
350 account, err := s.accountForEmail(ui.Email)
351 if err != nil {
352 glog.Errorf("account get failed: %v", err)
353 http.Redirect(w, r, "/", 302)
354 return
355 }
356 if account != "" {
357 s.sessionPut(w, r, "account", account)
358 http.Redirect(w, r, "/", 302)
359 } else {
360 s.sessionPut(w, r, "email", ui.Email)
361 http.Redirect(w, r, "/setup", 302)
362 }
363}
364
365func (s *server) viewOauthSetup(w http.ResponseWriter, r *http.Request) {
366 email := s.sessionGet(r, "email")
367 if email == "" {
368 glog.Errorf("No email")
369 http.Redirect(w, r, "/", 302)
370 return
371 }
372
373 if r.Method == "GET" {
374 tSetup.Execute(w, nil)
375 return
376 }
377
378 username := r.FormValue("username")
379 password := r.FormValue("password")
380 if !reAccount.MatchString(username) {
381 tSetup.Execute(w, map[string]string{
382 "Username": username,
383 "Error": "Invalid username - must be 3-16 a-z 0-9 - _",
384 })
385 return
386 }
387 if !rePassword.MatchString(password) {
388 tSetup.Execute(w, map[string]string{
389 "Username": username,
390 "Error": "Invalid password - must be 3-16 a-z A-Z 0-9 - _",
391 })
392 return
393 }
394
395 // this races. ugh. no way to list users. yolo.
396 err := createAccount(r.Context(), username, password)
397 if err != nil {
398 tSetup.Execute(w, map[string]string{
399 "Username": username,
400 "Error": "Account already exists, pick a different username",
401 })
402 return
403 }
404
405 err = s.db.Update(func(tx *bolt.Tx) error {
406 b := tx.Bucket([]byte("emailToAccount"))
407 v := string(b.Get([]byte(email)))
408 if v != "" {
409 s.sessionPut(w, r, "account", v)
410 http.Redirect(w, r, "/", 302)
411 return nil
412 }
413 b.Put([]byte(email), []byte(username))
414 s.sessionPut(w, r, "account", username)
415 http.Redirect(w, r, "/", 302)
416 return nil
417 })
418 if err != nil {
419 glog.Errorf("setup tx: %v", err)
420 http.Redirect(w, r, "/", 302)
421 }
422}
423func (s *server) viewReset(w http.ResponseWriter, r *http.Request) {
424 account := s.sessionGet(r, "account")
425 if account == "" {
426 glog.Errorf("No account")
427 http.Redirect(w, r, "/", 302)
428 return
429 }
430
431 password := r.FormValue("password")
432 if !rePassword.MatchString(password) {
433 fmt.Fprintf(w, "pick a 3-16 password with not too many special chars")
434 return
435 }
436
437 // this races. ugh. no way to list users. yolo.
438 err := ensureAccount(r.Context(), account, password)
439 if err != nil {
440 glog.Errorf("ensureAccount(%q, _): %v", account, err)
441 fmt.Fprintf(w, "something went wrong, lol")
442 return
443 }
444 fmt.Fprintf(w, "new password set.")
445}
446
447func (s *server) viewLogout(w http.ResponseWriter, r *http.Request) {
448 s.sessionPut(w, r, "account", "")
449 s.sessionPut(w, r, "email", "")
450 http.Redirect(w, r, "/", 302)
451}
452
453func (s *server) accountForEmail(email string) (string, error) {
454 res := ""
455 err := s.db.View(func(tx *bolt.Tx) error {
456 b := tx.Bucket([]byte("emailToAccount"))
457 v := b.Get([]byte(email))
458 res = string(v)
459 return nil
460 })
461 return res, err
462}
463
464type userinfo struct {
465 Email string `json:"email"`
466}