Serge Bazanski | 1572e52 | 2020-12-03 23:19:28 +0100 | [diff] [blame] | 1 | package main |
| 2 | |
| 3 | import ( |
| 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 | |
| 23 | var ( |
| 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 | |
| 36 | func init() { |
| 37 | flag.Set("logtostderr", "true") |
| 38 | } |
| 39 | |
| 40 | func 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 Bazanski | 7ea8e47 | 2020-12-04 10:48:37 +0100 | [diff] [blame] | 118 | http.HandleFunc("/spaceapi", s.viewSpaceAPI) |
Serge Bazanski | 1572e52 | 2020-12-03 23:19:28 +0100 | [diff] [blame] | 119 | |
| 120 | err = http.ListenAndServe(flagListen, nil) |
| 121 | if err != nil { |
| 122 | glog.Exitf("ListenAndServe: %v", err) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | type 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 | |
| 137 | var ( |
| 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> |
| 165 | Not 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/> |
| 172 | pick 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"> |
| 178 | username:<input name="username" value={{ .Username }}></input><br/> |
| 179 | password:<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> |
| 206 | Your 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 | |
| 218 | func (s *server) session(r *http.Request) *sessions.Session { |
| 219 | session, _ := s.store.Get(r, sessionName) |
| 220 | return session |
| 221 | } |
| 222 | |
| 223 | func (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 | |
| 235 | func (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 | |
| 241 | func (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 | |
| 266 | func (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 | |
| 283 | const sessionName = "wow" |
| 284 | |
| 285 | func (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 | |
| 298 | func (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 | |
| 311 | func (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 | |
| 366 | func (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 | } |
| 424 | func (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 | |
| 448 | func (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 | |
| 454 | func (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 | |
| 465 | type userinfo struct { |
| 466 | Email string `json:"email"` |
| 467 | } |