blob: aefbd1aaeadb80d5116b4681eaae81ed85cda960 [file] [log] [blame]
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"`
}