| package main |
| |
| import ( |
| "context" |
| "crypto/rand" |
| "encoding/hex" |
| "encoding/json" |
| "flag" |
| "fmt" |
| "html/template" |
| "io/ioutil" |
| "net/http" |
| "sync" |
| "time" |
| |
| "github.com/boltdb/bolt" |
| oidc "github.com/coreos/go-oidc" |
| "github.com/golang/glog" |
| "github.com/gorilla/sessions" |
| "golang.org/x/oauth2" |
| ) |
| |
| var ( |
| flagSOAPAddress string |
| flagSOAPUsername string |
| flagSOAPPassword string |
| flagListen string |
| flagSecret string |
| flagOAuthClientID string |
| flagOAuthClientSecret string |
| flagOAuthRedirectURL string |
| flagDB string |
| flagMOTD string |
| ) |
| |
| func init() { |
| flag.Set("logtostderr", "true") |
| } |
| |
| func main() { |
| flag.StringVar(&flagSOAPAddress, "soap_address", "http://127.0.0.1:7878", "Address of AC SOAP server") |
| flag.StringVar(&flagSOAPUsername, "soap_username", "test1", "SOAP username") |
| flag.StringVar(&flagSOAPPassword, "soap_password", "", "SOAP password") |
| flag.StringVar(&flagListen, "listen", "127.0.0.1:8080", "HTTP listen address") |
| flag.StringVar(&flagSecret, "secret", "", "Cookie secret") |
| flag.StringVar(&flagOAuthClientID, "oauth_client_id", "", "OAuth client ID") |
| flag.StringVar(&flagOAuthClientSecret, "oauth_client_secret", "", "OAuth client secret") |
| flag.StringVar(&flagOAuthRedirectURL, "oauth_redirect_url", "", "OAuth redirect URL") |
| flag.StringVar(&flagDB, "db", "db.db", "Path to database") |
| flag.StringVar(&flagMOTD, "motd", "", "Path to MOTD") |
| flag.Parse() |
| |
| if flagSecret == "" { |
| glog.Exitf("-secret must be set") |
| } |
| |
| var err error |
| var motd []byte |
| if flagMOTD == "" { |
| glog.Warningf("no MOTD defined, set -motd to get one") |
| } else { |
| motd, err = ioutil.ReadFile(flagMOTD) |
| if err != nil { |
| glog.Exitf("cannot read MOTD %q: %v", flagMOTD, err) |
| } |
| } |
| |
| db, err := bolt.Open(flagDB, 0600, nil) |
| if err != nil { |
| glog.Exitf("opening database failed: %v", err) |
| } |
| |
| provider, err := oidc.NewProvider(context.Background(), "https://sso.hackerspace.pl") |
| if err != nil { |
| glog.Exitf("newprovider: %v", err) |
| } |
| oauth2Config := oauth2.Config{ |
| ClientID: flagOAuthClientID, |
| ClientSecret: flagOAuthClientSecret, |
| RedirectURL: flagOAuthRedirectURL, |
| |
| // Discovery returns the OAuth2 endpoints. |
| Endpoint: provider.Endpoint(), |
| |
| // "openid" is a required scope for OpenID Connect flows. |
| Scopes: []string{oidc.ScopeOpenID, "profile:read"}, |
| } |
| |
| err = db.Update(func(tx *bolt.Tx) error { |
| for _, name := range []string{ |
| "emailToAccount", |
| } { |
| _, err := tx.CreateBucketIfNotExists([]byte(name)) |
| if err != nil { |
| return fmt.Errorf("create bucket %q: %s", name, err) |
| } |
| } |
| return nil |
| }) |
| if err != nil { |
| glog.Exitf("db setup failed: %v", err) |
| } |
| |
| s := &server{ |
| db: db, |
| store: sessions.NewCookieStore([]byte(flagSecret)), |
| oauth2: &oauth2Config, |
| motd: string(motd), |
| } |
| |
| http.HandleFunc("/", s.viewIndex) |
| http.HandleFunc("/login", s.viewLogin) |
| http.HandleFunc("/oauth", s.viewOauth) |
| http.HandleFunc("/callback", s.viewOauthCallback) |
| http.HandleFunc("/setup", s.viewOauthSetup) |
| http.HandleFunc("/reset", s.viewReset) |
| http.HandleFunc("/logout", s.viewLogout) |
| http.HandleFunc("/spaceapi", s.viewSpaceAPI) |
| |
| err = http.ListenAndServe(flagListen, nil) |
| if err != nil { |
| glog.Exitf("ListenAndServe: %v", err) |
| } |
| } |
| |
| type server struct { |
| db *bolt.DB |
| store *sessions.CookieStore |
| oauth2 *oauth2.Config |
| motd string |
| |
| onlineLock sync.RWMutex |
| onlineData []playerinfo |
| onlineDeadline time.Time |
| } |
| |
| var ( |
| tLogin = template.Must(template.New("login").Parse(`<html> |
| <title>super wow - who are you?</title> |
| <body> |
| <pre> |
| |
| ___ _ _ _ __ ___ _ __ __ _______ __ |
| / __| | | | '_ \ / _ \ '__| \ \ /\ / / _ \ \ /\ / / |
| \__ \ |_| | |_) | __/ | \ V V / (_) \ V V / |
| |___/\__,_| .__/ \___|_| \_/\_/ \___/ \_/\_/ |
| |_| |
| _ _ _ _ _ _ |
| | | _____ | | ___ (_)___ _ _ __ | | _____ | |_ _| |__ |
| | |/ / _ \| |/ _ \_ / _' | '_ \| |/ / _ \ | | | | | '_ \ |
| | < (_) | | __// / (_| | | | | < (_) | | | |_| | |_) | |
| |_|\_\___/|_|\___/___\__,_|_| |_|_|\_\___/ |_|\__,_|_.__/ |
| |
| _ _ __ |
| | | _____ | | ___ __ _ ___ \ \ |
| | |/ / _ \| |/ _ \/ _' |/ _ \ (_) | |
| | < (_) | | __/ (_| | (_) | _| | |
| |_|\_\___/|_|\___|\__, |\___/ (_) | |
| |___/ /_/ |
| |
| </pre> |
| <a href="/oauth">Sign in (or create new account) with HSWAW SSO</a>.<br/> |
| |
| <p> |
| Not a hswaw member? Talk to q3k. |
| </p> |
| </body>`)) |
| tSetup = template.Must(template.New("setup").Parse(`<html> |
| <title>super wow - setup account</title> |
| <body> |
| <b>hi, please provide details for your new WoW account</b><br/> |
| pick any username you want, pick a 3-16 character password that isn't the same as your sso password (duh)<br /> |
| (this isn't your character name, this will only be used to log into WoW) |
| {{ if .Error }} |
| <br /><b>error</b>: {{ .Error }}<br /> |
| {{ end }} |
| <form method="post" action="/setup"> |
| username:<input name="username" value={{ .Username }}></input><br/> |
| password:<input name="password" type="password"></input><br/> |
| <input type=submit value="create account"/> |
| </form> |
| </body>`)) |
| tIndex = template.Must(template.New("index").Parse(`<html> |
| <title>super wow</title> |
| <style type="text/css"> |
| body { |
| background-color: #fff; |
| } |
| table, th, td { |
| background-color: #eee; |
| padding: 0.2em 0.4em 0.2em 0.4em; |
| } |
| table th { |
| background-color: #c0c0c0; |
| } |
| table { |
| background-color: #fff; |
| border-spacing: 0.2em; |
| } |
| </style> |
| <body> |
| <b>Hello, {{ .Username }}.</b></br> |
| <a href="/logout">Log out.</a><br /> |
| {{ .MOTD }} |
| <p> |
| 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> |
| </p> |
| <b>Currently in-game:</b> |
| <table> |
| <tr><th>Account</th><th>Character</th></tr> |
| {{ range .Online }} |
| <tr><td>{{ .Account }}</td><td>{{ .Character }}</td></tr> |
| {{ end }} |
| </table> |
| </body>`)) |
| ) |
| |
| func (s *server) session(r *http.Request) *sessions.Session { |
| session, _ := s.store.Get(r, sessionName) |
| return session |
| } |
| |
| func (s *server) sessionGet(r *http.Request, k string) string { |
| v, ok := s.session(r).Values[k] |
| if !ok { |
| return "" |
| } |
| v2, ok := v.(string) |
| if !ok { |
| return "" |
| } |
| return v2 |
| } |
| |
| func (s *server) sessionPut(w http.ResponseWriter, r *http.Request, k, v string) { |
| session := s.session(r) |
| session.Values[k] = v |
| session.Save(r, w) |
| } |
| |
| func (s *server) online(ctx context.Context) []playerinfo { |
| s.onlineLock.RLock() |
| if s.onlineData == nil || time.Now().After(s.onlineDeadline) { |
| s.onlineLock.RUnlock() |
| s.onlineLock.Lock() |
| data, err := onlinelist(ctx) |
| if err != nil { |
| glog.Errorf("onlinelist fatch failed: %v", err) |
| s.onlineDeadline = time.Now().Add(10 * time.Second) |
| } else { |
| s.onlineData = data |
| s.onlineDeadline = time.Now().Add(60 * time.Second) |
| } |
| s.onlineLock.Unlock() |
| s.onlineLock.RLock() |
| } |
| |
| res := make([]playerinfo, len(s.onlineData)) |
| for i, pi := range s.onlineData { |
| res[i] = pi |
| } |
| s.onlineLock.RUnlock() |
| return res |
| } |
| |
| func (s *server) viewIndex(w http.ResponseWriter, r *http.Request) { |
| account := s.sessionGet(r, "account") |
| if account == "" { |
| http.Redirect(w, r, "/login", 302) |
| return |
| } |
| err := tIndex.Execute(w, map[string]interface{}{ |
| "Username": account, |
| "Online": s.online(r.Context()), |
| "MOTD": template.HTML(s.motd), |
| }) |
| if err != nil { |
| glog.Errorf("/: %v", err) |
| return |
| } |
| } |
| |
| const sessionName = "wow" |
| |
| func (s *server) viewLogin(w http.ResponseWriter, r *http.Request) { |
| account := s.sessionGet(r, "account") |
| if account != "" { |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| err := tLogin.Execute(w, nil) |
| if err != nil { |
| glog.Errorf("/login: %v", err) |
| return |
| } |
| } |
| |
| func (s *server) viewOauth(w http.ResponseWriter, r *http.Request) { |
| stateBytes := make([]byte, 8) |
| _, err := rand.Read(stateBytes) |
| if err != nil { |
| glog.Errorf("/oauth: random: %v", err) |
| return |
| } |
| state := hex.EncodeToString(stateBytes) |
| s.sessionPut(w, r, "ostate", state) |
| url := s.oauth2.AuthCodeURL(state) |
| http.Redirect(w, r, url, http.StatusTemporaryRedirect) |
| } |
| |
| func (s *server) viewOauthCallback(w http.ResponseWriter, r *http.Request) { |
| if r.FormValue("errors") != "" { |
| fmt.Fprintf(w, "Errors: %s", r.FormValue("errors")) |
| return |
| } |
| state := s.sessionGet(r, "ostate") |
| if state == "" { |
| glog.Errorf("No state") |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| if state != r.FormValue("state") { |
| glog.Errorf("Invalid state") |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| oauth2Token, err := s.oauth2.Exchange(r.Context(), r.FormValue("code")) |
| if err != nil { |
| glog.Errorf("Exchange failed: %v", err) |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| client := s.oauth2.Client(r.Context(), oauth2Token) |
| res, err := client.Get("https://sso.hackerspace.pl/api/1/userinfo") |
| if err != nil { |
| glog.Errorf("Userinfo failed: %v", err) |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| defer res.Body.Close() |
| data, _ := ioutil.ReadAll(res.Body) |
| |
| ui := userinfo{} |
| err = json.Unmarshal(data, &ui) |
| if err != nil || ui.Email == "" { |
| glog.Errorf("Userinfo unarshal failed: %v", err) |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| |
| account, err := s.accountForEmail(ui.Email) |
| if err != nil { |
| glog.Errorf("account get failed: %v", err) |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| if account != "" { |
| s.sessionPut(w, r, "account", account) |
| http.Redirect(w, r, "/", 302) |
| } else { |
| s.sessionPut(w, r, "email", ui.Email) |
| http.Redirect(w, r, "/setup", 302) |
| } |
| } |
| |
| func (s *server) viewOauthSetup(w http.ResponseWriter, r *http.Request) { |
| email := s.sessionGet(r, "email") |
| if email == "" { |
| glog.Errorf("No email") |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| |
| if r.Method == "GET" { |
| tSetup.Execute(w, nil) |
| return |
| } |
| |
| username := r.FormValue("username") |
| password := r.FormValue("password") |
| if !reAccount.MatchString(username) { |
| tSetup.Execute(w, map[string]string{ |
| "Username": username, |
| "Error": "Invalid username - must be 3-16 a-z 0-9 - _", |
| }) |
| return |
| } |
| if !rePassword.MatchString(password) { |
| tSetup.Execute(w, map[string]string{ |
| "Username": username, |
| "Error": "Invalid password - must be 3-16 a-z A-Z 0-9 - _", |
| }) |
| return |
| } |
| |
| // this races. ugh. no way to list users. yolo. |
| err := createAccount(r.Context(), username, password) |
| if err != nil { |
| tSetup.Execute(w, map[string]string{ |
| "Username": username, |
| "Error": "Account already exists, pick a different username", |
| }) |
| return |
| } |
| |
| err = s.db.Update(func(tx *bolt.Tx) error { |
| b := tx.Bucket([]byte("emailToAccount")) |
| v := string(b.Get([]byte(email))) |
| if v != "" { |
| s.sessionPut(w, r, "account", v) |
| http.Redirect(w, r, "/", 302) |
| return nil |
| } |
| b.Put([]byte(email), []byte(username)) |
| s.sessionPut(w, r, "account", username) |
| http.Redirect(w, r, "/", 302) |
| return nil |
| }) |
| if err != nil { |
| glog.Errorf("setup tx: %v", err) |
| http.Redirect(w, r, "/", 302) |
| } |
| } |
| func (s *server) viewReset(w http.ResponseWriter, r *http.Request) { |
| account := s.sessionGet(r, "account") |
| if account == "" { |
| glog.Errorf("No account") |
| http.Redirect(w, r, "/", 302) |
| return |
| } |
| |
| password := r.FormValue("password") |
| if !rePassword.MatchString(password) { |
| fmt.Fprintf(w, "pick a 3-16 password with not too many special chars") |
| return |
| } |
| |
| // this races. ugh. no way to list users. yolo. |
| err := ensureAccount(r.Context(), account, password) |
| if err != nil { |
| glog.Errorf("ensureAccount(%q, _): %v", account, err) |
| fmt.Fprintf(w, "something went wrong, lol") |
| return |
| } |
| fmt.Fprintf(w, "new password set.") |
| } |
| |
| func (s *server) viewLogout(w http.ResponseWriter, r *http.Request) { |
| s.sessionPut(w, r, "account", "") |
| s.sessionPut(w, r, "email", "") |
| http.Redirect(w, r, "/", 302) |
| } |
| |
| func (s *server) accountForEmail(email string) (string, error) { |
| res := "" |
| err := s.db.View(func(tx *bolt.Tx) error { |
| b := tx.Bucket([]byte("emailToAccount")) |
| v := b.Get([]byte(email)) |
| res = string(v) |
| return nil |
| }) |
| return res, err |
| } |
| |
| type userinfo struct { |
| Email string `json:"email"` |
| } |