commit 5cffe2b8aeeb894000aa4217a17e82354b0594ca Author: Auke Kok Date: Sat Dec 23 19:05:04 2017 -0800 Initial commit. diff --git a/main.go b/main.go new file mode 100644 index 0000000..9b26d62 --- /dev/null +++ b/main.go @@ -0,0 +1,820 @@ +package main + +import ( + "crypto/rand" + "crypto/tls" + "database/sql" + "encoding/base32" + "encoding/json" + "github.com/badoux/checkmail" + _ "github.com/mattn/go-sqlite3" // MIT licensed. + "github.com/spf13/viper" + "gopkg.in/gomail.v2" + "io/ioutil" + "log" + "net" + "net/http" + "net/http/fcgi" + "os" + "time" +) + +// DB related stuff +var db *sql.DB + +type Token struct { + token string + cookie string + created int64 + expiry int64 + request []byte + confirmed bool +} + +type Server struct { + server_id string + created int64 + email string + data string + ip string +} + +type Identity struct { + email string + created int64 + data string +} + +type Player struct { + email string + name string + server_id string + created int64 + data string +} + +// JSON data blobs +type Player_data struct { + Auth_required string `json:"auth_required"` +} + +// JSON interface to WWW, and gameserver +type Serverdata struct { + Owner string `json:"owner"` + Name string `json:"name"` + Address string `json:"address"` + Url string `json:"url"` + Announce string `json:"announce"` + AnnounceUrl string `json:"announce_url"` +} + +type tfa_request struct { + // required + Request_type string `json:"request_type"` + Remote_ip string `json:"remote_ip"` + // optional, need to verify they're present at a later stage + Email string `json:"email",omitempty` + Player string `json:"player",omitempty` + Server_id string `json:"server_id",omitempty` + Token string `json:"token",omitmempty` + Cookie string `json:"cookie",omitempty` + Serverdata Serverdata `json:"server_data"` +} + +type tfa_response struct { + Result string `json:"result"` + Info string `json:"info"` + // optional + Data map[string]string `json:"data"` +} + +// misc functions +func make_token() string { + b := make([]byte, 24) + _, err := rand.Read(b) + if err != nil { + log.Fatal("Error creating token: ", err) + } + + s := base32.StdEncoding.EncodeToString(b) + + return s[:len(s)-1] +} + +func validate_email(email string) bool { + err := checkmail.ValidateFormat(email) + if err != nil { + log.Printf("email: %v: %v\n", email, err) + return false + } + //FIXME enable this when not running behind a NAT + //err = checkmail.ValidateHost(email) + //if err != nil { + // log.Printf("email: %v: %v\n", email, err) + //} + //if smtpErr, ok := err.(checkmail.SmtpError); ok && err != nil { + // log.Printf("email: %v: code: %s, msg: %s", email, smtpErr.Code(), smtpErr) + // return false + //} + return true +} + +func do_email(email string, message string) bool { + m := gomail.NewMessage() + m.SetHeader("From", viper.GetString("email_sender")) + m.SetHeader("To", email) + m.SetHeader("Subject", "Minetest 2-factor confirmation request") + m.SetBody("text/plain", message) + + d := gomail.Dialer{Host: viper.GetString("smtp_server"), Port: viper.GetInt("smtp_port")} + if viper.GetBool("smtp_verify_certificate") == false { + d.TLSConfig = &tls.Config{InsecureSkipVerify: true} + } + if err := d.DialAndSend(m); err != nil { + log.Println(err) + return false + } + + return true +} + +type FastCGIServer struct{} + +func (s FastCGIServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // track IP of client, we'll need it later for some transactions + ip, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + http.Error(w, err.Error(), 500) + log.Print("Request: unable to identify peer\n") + return + } + remoteip := net.ParseIP(ip).String() + + // parse POST data + body, err := ioutil.ReadAll(req.Body) + defer req.Body.Close() + if err != nil { + http.Error(w, err.Error(), 500) + log.Printf("Request: %v: unable to read body\n", remoteip) + return + } + + var rq tfa_request + err = json.Unmarshal(body, &rq) + if err != nil { + http.Error(w, err.Error(), 500) + log.Printf("Request: %v: malformed JSON data\n", remoteip) + return + } + rq.Remote_ip = remoteip + + // create response + var rp tfa_response + + // prefetch server name for requestst that want it + servername := "(no name)" + if rq.Request_type == "REG" || rq.Request_type == "AUTH" { + // get relevant info, ignore errors + var data []byte + err = db.QueryRow("SELECT data FROM servers WHERE server_id=?", rq.Server_id).Scan(&data) + if err == nil { + var sd Serverdata + err = json.Unmarshal(data, &sd) + if err == nil && sd.Name != "" { + servername = sd.Name + } + } + } + + // validate request origin is valid + var orip string + err = db.QueryRow("SELECT ip FROM servers WHERE server_id=?", rq.Server_id).Scan(&orip) + if err != nil || orip != remoteip { + switch rq.Request_type { + case "CONFIRM": + case "SERVER": + case "SERVERSTAT": + case "SERVERIP": + case "SERVERIPSTAT": + default: + rp = tfa_response{"SERVERIPFAIL", "The server IP address changed. The server owner will need to confirm\nthis change before normal events can be handled again.", nil} + goto send_response + } + } + + // process request + switch rq.Request_type { + case "REG": + if rq.Email == "" || rq.Player == "" || rq.Server_id == "" { + rp = tfa_response{"REGFAIL", "Registration failed, insufficient data.", nil} + break + } + if !validate_email(rq.Email) { + rp = tfa_response{"REGFAIL", "Registration failed, email invalid.", nil} + break + } + + // check identities table for rq.Email + var created int64 + err := db.QueryRow("SELECT created FROM identities WHERE email=?", rq.Email).Scan(&created) + if err == nil { + // already created? could just happen + var created int64 + err = db.QueryRow("SELECT created FROM players WHERE email=? AND player=?", rq.Email, rq.Player).Scan(&created) + if err == nil { + rp = tfa_response{"REGOK", "This email is already registered.", nil} + break + } + // store the server/player combo + _, err = db.Exec("INSERT INTO players(email, name, server_id, created, data) VALUES (?, ?, ?, ?, ?)", + rq.Email, rq.Player, rq.Server_id, time.Now().Unix(), "{}") + if err != nil { + rp = tfa_response{"REGFAIL", "Internal server error.", nil} + break + } + // looks like this was already registered as an identity! + rp = tfa_response{"REGOK", "This email is already registered.", nil} + break + } + + // no existing identity + token := make_token() + cookie := make_token() + + // send the confirmation email + if !do_email(rq.Email, + "\nHello,\n\n"+ + "You've received this request because you or someone registered this email\n"+ + "address on a minetest server at \""+servername+"\".\n\n"+ + "If this wasn't you, you can safely ignore this email. If it was you, please\n"+ + "click the following link to confirm your registration:\n\n"+ + " " + viper.GetString("base_url") + "confirm?t="+token+"\n\n") { + rp = tfa_response{"REGFAIL", "Registration failed, unable to send email.", nil} + break + } + + // store the token + j, err := json.Marshal(rq) + if err != nil { + log.Println(err) + rp = tfa_response{"REGFAIL", "Registration failed, internal server error.", nil} + break + } + t := Token{token, cookie, time.Now().Unix(), time.Now().Unix() + 300, j, false} + _, err = db.Exec("INSERT INTO tokens(token, cookie, created, expiry, request) VALUES (?, ?, ?, ?, ?)", + t.token, t.cookie, t.created, t.expiry, t.request) + if err != nil { + log.Println(err) + rp = tfa_response{"REGFAIL", "Registration failed, internal server error.", nil} + break + } + + rp = tfa_response{"REGPEND", "Mail sent. Check your mailbox.", nil} + rp.Data = make(map[string]string) + rp.Data["Cookie"] = cookie + case "REGSTAT": + var created int64 + err := db.QueryRow("SELECT created FROM identities WHERE email=?", rq.Email).Scan(&created) + if err == nil { + rp = tfa_response{"REGOK", "Registration succeeded.", nil} + break + } + // check if a token exists. + var confirmed bool + err = db.QueryRow("SELECT confirmed FROM tokens WHERE cookie=?", rq.Cookie).Scan(&confirmed) + if err == nil { + if confirmed { + // badness + rp = tfa_response{"REGFAIL", "Internal server error.", nil} + break + } + rp = tfa_response{"REGPEND", "Mail sent. Check your mailbox.", nil} + break + } + rp = tfa_response{"REGFAIL", "Registration failed.", nil} + case "AUTH": + // check server token + username combo exists + var email string + err := db.QueryRow("SELECT email FROM players WHERE name=? AND server_id=?", rq.Player, rq.Server_id).Scan(&email) + if err != nil { + rp = tfa_response{"AUTHFAIL", "Authentication failed.", nil} + break + } + + // send an AUTH email + token := make_token() + cookie := make_token() + + // send the confirmation email + if !do_email(email, + "\nHello,\n\n"+ + "You've received this request because you or someone wants to authenticate\n"+ + "using this email address on a minetest server at \""+servername+"\".\n\n"+ + "If this wasn't you, you can safely ignore this email. If it was you, please\n"+ + "click the following link to confirm your authentication:\n\n"+ + " " + viper.GetString("base_url") + "confirm?t="+token+"\n\n") { + rp = tfa_response{"AUTHFAIL", "Authentication failed, unable to send email.", nil} + break + } + + // store the token + j, err := json.Marshal(rq) + if err != nil { + log.Println(err) + rp = tfa_response{"AUTHFAIL", "Authentication failed, internal server error.", nil} + break + } + t := Token{token, cookie, time.Now().Unix(), time.Now().Unix() + 300, j, false} + _, err = db.Exec("INSERT INTO tokens(token, cookie, created, expiry, request) VALUES (?, ?, ?, ?, ?)", + t.token, t.cookie, t.created, t.expiry, t.request) + if err != nil { + log.Println(err) + rp = tfa_response{"AUTHFAIL", "Authentication failed, internal server error.", nil} + break + } + + rp = tfa_response{"AUTHPEND", "Mail sent. Check your mailbox.", nil} + rp.Data = make(map[string]string) + rp.Data["Cookie"] = cookie + case "AUTHSTAT": + // check tokens + var confirmed bool + var expiry int64 + err := db.QueryRow("SELECT confirmed, expiry FROM tokens WHERE cookie=?", rq.Cookie).Scan(&confirmed, &expiry) + if err != nil { + // there is no token + rp = tfa_response{"AUTHFAIL", "Server registration failed.", nil} + break + } + if time.Now().Unix() > expiry { + rp = tfa_response{"AUTHFAIL", "Authentication failed.", nil} + break + } + if !confirmed { + rp = tfa_response{"AUTHPEND", "Mail sent. Check your mailbox.", nil} + break + } + rp = tfa_response{"AUTHOK", "Authentication succeeded.", nil} + case "ACCT": + // this is sent by a server to see if the account is required to + // authenticate, and / or to inspect stat data (not implemented yet) + var data []byte + var email string + err := db.QueryRow("SELECT data, email FROM players WHERE name=? AND server_id=?", rq.Player, rq.Server_id).Scan(&data, &email) + if err != nil { + rp = tfa_response{"ACCTFAIL", "Request failed.", nil} + break + } + var pd Player_data + err = json.Unmarshal(data, &pd) + if pd.Auth_required != "1" { + err := db.QueryRow("SELECT data FROM identities WHERE email=?", email).Scan(&data) + if err != nil { + rp = tfa_response{"ACCTFAIL", "Request failed.", nil} + break + } + err = json.Unmarshal(data, &pd) + if pd.Auth_required != "1" { + rp = tfa_response{"ACCTOK", "Account info retrieved.", nil} + break + } + } + rp = tfa_response{"ACCTOK", "Account info retrieved. Player must authenticate", nil} + rp.Data = make(map[string]string) + rp.Data["Auth_required"] = "1" + // get playerdata struct json + case "UPDATES": + // check if server sent server_data changes + if rq.Serverdata.Owner != "" { + // refresh server data + //FIXME make sure all the required fields are present again + s, err := json.Marshal(rq.Serverdata) + if err != nil { + log.Println("Error storing Serverdata") + } else { + _, err = db.Exec("UPDATE servers set data=? WHERE server_id=?", + s, rq.Server_id) + if err != nil { + log.Println("Error updating Serverdata") + } else { + log.Println(remoteip + ": Updated server info for \"" + servername + "\"") + } + } + } + //FIXME implement some updates - rp = tfa_response{"UPDATE", "Changes requested.", nil} + rp = tfa_response{"NOUPDATES", "No changes for server.", nil} + case "SERVER": + // server registration request. + if rq.Email == "" { + rp = tfa_response{"SERVERFAIL", "Registration failed, insufficient data.", nil} + break + } + if rq.Server_id != "" { + rp = tfa_response{"SERVERFAIL", "Registration failed, you provided a server id. This server is already registered.", nil} + break + } + if !validate_email(rq.Email) { + rp = tfa_response{"SERVERFAIL", "Registration failed, email invalid.", nil} + break + } + + // validate serverdata is sufficient + if rq.Serverdata.Owner == "" { + rp = tfa_response{"SERVERFAIL", "Registration failed, insufficient data.", nil} + break + } + + token := make_token() + cookie := make_token() + + // send the confirmation email + if !do_email(rq.Email, + "\nHello,\n\n"+ + "You've received this request because you or someone wants to register\n"+ + "a server using this email address at \""+remoteip+"\".\n\n"+ + "If this wasn't you, you can safely ignore this email. If it was you, please\n"+ + "click the following link to confirm your server registration:\n\n"+ + " " + viper.GetString("base_url") + "confirm?t="+token+"\n\n") { + rp = tfa_response{"SERVERFAIL", "Server registration failed, unable to send email.", nil} + break + } + + // store the token as "server_id" in the request + rq.Server_id = token + + // store the token including original request data + j, err := json.Marshal(rq) + if err != nil { + log.Println(err) + rp = tfa_response{"SERVERFAIL", "Server registration failed, internal server error.", nil} + break + } + t := Token{token, cookie, time.Now().Unix(), time.Now().Unix() + 300, j, false} + _, err = db.Exec("INSERT INTO tokens(token, cookie, created, expiry, request) VALUES (?, ?, ?, ?, ?)", + token, cookie, t.created, t.expiry, t.request) + if err != nil { + log.Println(err) + rp = tfa_response{"SERVERFAIL", "Server registration failed, internal server error.", nil} + break + } + + rp = tfa_response{"SERVERPEND", "Mail sent. Check your mailbox.", nil} + // send the token to the server, so that the server can use it to validate the registration + // after confirmation + rp.Data = make(map[string]string) + rp.Data["Cookie"] = cookie + case "SERVERIP": + // server IP address request. + if rq.Email == "" || rq.Server_id == "" { + rp = tfa_response{"SERVERIPFAIL", "Server IP change failed, insufficient data.", nil} + break + } + + // validate email is actually the one on file for this server + var serverid, email string + err := db.QueryRow("SELECT server_id, email FROM servers WHERE server_id=? and email=?", rq.Server_id, rq.Email).Scan(&serverid, &email) + if err != nil { + rp = tfa_response{"SERVERIPFAIL", "Server IP change failed, invalid data.", nil} + break + } + + token := make_token() + cookie := make_token() + + // send the confirmation email + if !do_email(rq.Email, + "\nHello,\n\n"+ + "You've received this request because you or someone wants to change the IP\n"+ + "address of a server using this email address at \""+remoteip+"\".\n\n"+ + "If this wasn't you, you can safely ignore this email. If it was you, please\n"+ + "click the following link to confirm your server IP change:\n\n"+ + " " + viper.GetString("base_url") + "confirm?t="+token+"\n\n") { + rp = tfa_response{"SERVERIPFAIL", "Server IP change failed, unable to send email.", nil} + break + } + + // store the token including original request data + j, err := json.Marshal(rq) + if err != nil { + log.Println(err) + rp = tfa_response{"SERVERIPFAIL", "Server IP change failed, internal server error.", nil} + break + } + t := Token{token, cookie, time.Now().Unix(), time.Now().Unix() + 300, j, false} + _, err = db.Exec("INSERT INTO tokens(token, cookie, created, expiry, request) VALUES (?, ?, ?, ?, ?)", + token, cookie, t.created, t.expiry, t.request) + if err != nil { + log.Println(err) + rp = tfa_response{"SERVERIPFAIL", "Server IP change failed, internal server error.", nil} + break + } + + rp = tfa_response{"SERVERIPPEND", "Mail sent. Check your mailbox.", nil} + // send the token to the server, so that the server can use it to validate the registration + // after confirmation + rp.Data = make(map[string]string) + rp.Data["Cookie"] = cookie + case "SERVERSTAT": + // verify that server_id is registered + var token string + var expiry int64 + var confirmed bool + err := db.QueryRow("SELECT token, expiry, confirmed FROM tokens WHERE cookie=?", rq.Cookie).Scan( + &token, &expiry, &confirmed) + if err == nil { + var created int + err := db.QueryRow("SELECT created FROM servers WHERE server_id=?", token).Scan(&created) + if err == nil { + rp = tfa_response{"SERVEROK", "Server registration succeeded.", nil} + rp.Data = make(map[string]string) + rp.Data["Server_id"] = token + break + } + + if time.Now().Unix() > expiry { + rp = tfa_response{"SERVERFAIL", "Server registration failed.", nil} + break + } + if !confirmed { + rp = tfa_response{"SERVERPEND", "Server registration pending. Check your email.", nil} + break + } + } + + rp = tfa_response{"SERVERFAIL", "Server registration failed. Try again", nil} + break + case "SERVERIPSTAT": + // verify that server_id is registered + var expiry int64 + var confirmed bool + err := db.QueryRow("SELECT expiry, confirmed FROM tokens WHERE cookie=?", rq.Cookie).Scan( + &expiry, &confirmed) + if err == nil { + var ip string + err := db.QueryRow("SELECT ip FROM servers WHERE server_id=?", rq.Server_id).Scan(&ip) + if err == nil { + if rq.Remote_ip == ip { + rp = tfa_response{"SERVERIPOK", "Server IP change succeeded.", nil} + break + } + } + + if time.Now().Unix() > expiry { + rp = tfa_response{"SERVERIPFAIL", "Server IP change failed.", nil} + break + } + if !confirmed { + rp = tfa_response{"SERVERIPPEND", "Server IP change pending. Check your email.", nil} + break + } + } + + rp = tfa_response{"SERVERIPFAIL", "Server IP change failed. Try again", nil} + break + + // + // www initiated requests + // + case "CONFIRM": + if rq.Token == "" { + rp = tfa_response{"CONFIRMFAIL", "Invalid confirmation.", nil} + break + } + + // fetch entry from tokens table + var ot Token + err := db.QueryRow("SELECT created, expiry, request, confirmed FROM tokens WHERE token=?", + rq.Token).Scan(&ot.created, &ot.expiry, &ot.request, &ot.confirmed) + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // check if we didn't already do this + if ot.confirmed { + rp = tfa_response{"CONFIRMOK", "Request already completed before.", nil} + break + } + + // check if token not too old + if time.Now().Unix() > ot.expiry { + rp = tfa_response{"CONFIRMFAIL", "Request token expired. Create a new request.", nil} + break + } + + // fetch original request + var or tfa_request + err = json.Unmarshal(ot.request, &or) + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // complete the transaction! + if or.Request_type == "REG" { + _, err = db.Exec("INSERT INTO identities (email, created, data) VALUES (?, ?, ?)", + or.Email, time.Now().Unix(), "{}") + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // Mark as done + _, err = db.Exec("UPDATE tokens SET confirmed=? WHERE token=?", + true, rq.Token) + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // store the server/player combo + _, err = db.Exec("INSERT INTO players(email, name, server_id, created, data) VALUES (?, ?, ?, ?, ?)", + or.Email, or.Player, or.Server_id, time.Now().Unix(), "{}") + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // Nothing left to do + rp = tfa_response{"CONFIRMOK", "Request completed. Your identity is now registered.", nil} + break + + } else if or.Request_type == "AUTH" { + // Mark as done + _, err = db.Exec("UPDATE tokens SET confirmed=? WHERE token=?", + true, rq.Token) + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // Nothing left to do + rp = tfa_response{"CONFIRMOK", "Request completed. You are now authenticated.", nil} + break + + } else if or.Request_type == "SERVER" { + s, err := json.Marshal(or.Serverdata) + if err != nil { + s = []byte("{}") + log.Println("Error storing Serverdata") + } + _, err = db.Exec("INSERT INTO servers (server_id, email, created, data, ip) VALUES (?, ?, ?, ?, ?)", + or.Server_id, or.Email, time.Now().Unix(), s, or.Remote_ip) + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // Mark as done + _, err = db.Exec("UPDATE tokens SET confirmed=? WHERE token=?", + true, rq.Token) + + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // Nothing left to do + rp = tfa_response{"CONFIRMOK", "Request completed. Your server is now registered.", nil} + break + + } else if or.Request_type == "SERVERIP" { + _, err = db.Exec("UPDATE servers SET ip=? WHERE server_id=?", + or.Remote_ip, or.Server_id) + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // Mark as done + _, err = db.Exec("UPDATE tokens SET confirmed=? WHERE token=?", + true, rq.Token) + + if err != nil { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + // Nothing left to do + rp = tfa_response{"CONFIRMOK", "Request completed. Your server IP address now changed.", nil} + break + } else { + rp = tfa_response{"CONFIRMFAIL", "Internal server error.", nil} + break + } + + //passwdchange + //emailchange + default: + rp = tfa_response{"UNK", "Unknown request type. Don't do that again.", nil} + } + +send_response: + // and send to the client + output, err := json.Marshal(rp) + if err != nil { + http.Error(w, err.Error(), 500) + log.Print("Response: formatting response failed\n") + return + } + + log.Printf("%v: %v->%v (%v) \"%v\"\n", + remoteip, rq.Request_type, rp.Result, + rq.Player, rp.Info) + + w.Header().Set("Content-Type", "application/json") + w.Write(output) + + //FIXME prune tokens +} + +func main() { + log.SetFlags(0) + + // config stuffs + viper.SetConfigName("mt2fa") + viper.SetConfigType("yaml") + + viper.AddConfigPath("/usr/share/defaults/etc") + viper.AddConfigPath("/etc") + viper.AddConfigPath("$HOME/.config") + + viper.SetDefault("socket", "/run/mt2fa/sock") + + viper.SetDefault("email_sender", "nobody@localhost.localdomain") + viper.SetDefault("smtp_server", "localhost") + viper.SetDefault("smtp_port", 587) + viper.SetDefault("smtp_verify_certificate", true) + viper.SetDefault("sqlite_db", "mt2fa.sqlite") + viper.SetDefault("base_url", "https://localhost/") + + err := viper.ReadInConfig() + if err != nil { + log.Fatal("Error in confog file: ", err) + } + + // listen on fcgi socket + s := viper.GetString("socket") + os.Remove(s) + + listener, err := net.Listen("unix", s) + if err != nil { + log.Fatal("mt2fa: net.Listen: ", err) + } + + os.Chmod(s, 0666) + + defer listener.Close() + + // open our db + db, err = sql.Open("sqlite3", viper.GetString("sqlite_db")) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + // initialize our db as needed + createStmt := ` + CREATE TABLE IF NOT EXISTS tokens ( + token TEXT NOT NULL PRIMARY KEY, + cookie TEXT NOT NULL, + created INTEGER NOT NULL, + expiry INTEGER NOT NULL, + request TEXT NOT NULL, + confirmed BOOLEAN DEFAULT FALSE + ); + + CREATE TABLE IF NOT EXISTS servers ( + server_id TEXT NOT NULL PRIMARY KEY, + created INTEGER NOT NULL, + email TEXT NOT NULL, + data TEXT NOT NULL, + ip TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS identities ( + email TEXT NOT NULL PRIMARY KEY, + created INTEGER NOT NULL, + data TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS players ( + email TEXT NOT NULL, + name TEXT NOT NULL, + server_id TEXT NOT NULL, + created INTEGER NOT NULL, + data TEXT NOT NULL + ); + ` + _, err = db.Exec(createStmt) + if err != nil { + log.Fatal("%q: %s\n", err, createStmt) + } + + // serve requests. + h := new(FastCGIServer) + + log.Print("mt2fa: started") + + err = fcgi.Serve(listener, h) +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2ea813c --- /dev/null +++ b/readme.md @@ -0,0 +1,290 @@ + +## mt2fa-server - Minetest 2factor auth + +Provides a simple 2-factor auth module for verifying and maintaining +verification of player identities. Players are required to register +an email address with the 2fa service, and they will receive a login +token through the 2fa service, which is needed for all logins. + +A remote 2fa service handles the sending and receiving of emails and +creating the 2fa tokens. This server can be used by many different +servers at the same time. + +## License + + (C) 2018 Auke Kok + + Permission to use, copy, modify, and/or distribute this software + for any purpose with or without fee is hereby granted, provided + that the above copyright notice and this permission notice appear + in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR +BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + +## Operation modes + +### 2FA registration voluntary / required + +In this simplified mode, an account may, or must be linked to an +email address, but login does not require a 2FA token. This allows +players to recover their lost passwords without requiring the player +to provide a valid 2FA token on each login. + +### 2FA authentication voluntary / required + +In this mode, the player may, or must enroll in full 2FA authentication +on a voluntary basis. The player must provide a valid 2FA token on +each login after enrollment. + +If authentication is required, but registration isn't, then players +who registered are required to authenticate with 2fa, but others can +continue to use normal login. + +Each server operator can set minimum requirements. E.g. a server could +require registration, but may leave authentication voluntary. + +## client mod + +A client mod handles login of new players and requires them to provide +a valid email address. The 2fa service then is queried and if the email +verification succeeds, the client will receive an email with a valid +2fa login token. Once the login token is entered into the game UI, or +confirmed back to the 2FA service, the player is granted permissions +to interact with the game. + +## 2fa server + +The 2fa server handles incoming game server requests for either new +registrations, or for login events. These result in emails being +sent to the email address. In the registration email, the user is +simply requested to `reply` to the email. In the login event, the +user is provided a one-time password. The user has to enter this +one time password into their game client in order to access game +functionality. Or, the user clicks a link in the email, or, replies +to the email to confirm. + +The 2FA server then provides a limited time token to the authentication +mod on the game server. + +## account registration + +Accounts are registered with the 2fa server, which holds the database +with all the valid accounts. These accounts can be confirmed in +several ways. The main key for each account is a valid e-mail address. + +The player receives account emails on their registered email address. +In the registration confirmation, a token is embedded that can be used +to activate the account and therefore make it playable. + +The user can confirm the account by responding back to the 2fa server. +This can be done in several ways. + +1. https - the user clicks a link with a token in the email. + +Not implemented: + +2. smtp - the user replies to the email with the token. +3. in game - the user enters the token into the game. + +All these methods result in a confirmation event being sent to the 2fa +server, and then activation of the account in the account database. + +## account login + +If required by the game server, authentication is performed by sending the +player an email with a token that the player needs to reply, click, or +enter in the game client. Once the game server receives the token through +either direct input, or by confirmation from the 2FA server, the client is +granted interact privileges on the game server. + +## account recovery + +If the player forgets their game server password, the client can attempt +recovery of the game server account by initiating an account recovery. +The game server passes the account recovery request to the 2FA server, +which requests confirmation by the player through e-mail. Once the player +confirms the token through email, click or direct input, the client +may enter a new password for the game server directly into the game +server. + +## account exclusivity + +(not implemented) + +If the player opts in, or, if the server requires it, the account +becomes single-use. This will force the player offline on a server +if the player logs in on another server correctly. + +## account monitoring + +(not implemented) + +Players can monitor and audit their own account information, +authentication requests, failures, recovery attempts and other data +that resides on the 2FA server. + +## protocols + +The server communicates in several ways through other protocols: + +SMTP: +- only outgoing. The server sends emails over SMTP. +- if a user replies, a smtp to https bridge will assure the server + sees the verification + +HTTPS: +- incoming: + - To receive new account requests + - to receive 2fa login requests + - over the web: receive account verification link clicks +- bidir: + - answer 2fa login status requests + +## banning/abuse + +(not implemented) + +The 2fa server can store account status information, and the game server +can retrieve this information. + +The following data items are stored for each account: + +ro: data can be retrieved for the account +rw: data can be provided/changed for the account +public: all servers may access this data +private: the data is private to the server + +- creation date (public, ro) +- banned here (private, rw) +- bancount here (private, ro) +- playername (private, rw) +- banned (public, ro) +- bancount (public, ro) + +The 2fa server does not *act* on this information, it merely provides +it to the servers and acts as a storage of this information. Any player +objection on how this data is *used* by the server owner should be +directed to the server owner. + +## changing email addresses + +(not implemented) + +requires access to both old and new accounts, and verification from both +those accounts, by SMTP mails to both addresses. + +## scenario's + +* registration + + gameserver -> "REG" packet -> m2tfa + mt2fa -> "REGPEND" -> gameserver + mt2fa -> "REGFAIL" -> gameserver + mt2fa -> SMTP cookie -> mailbox + mailbox -> click link -> mt2fa (reg ok/reg fail) + + gameserver -> "REGSTAT" -> mt2fa + mt2fa -> REGOK + mt2fa -> REGPEND + mt2fa -> REGFAIL + +* authentication + + gameserver -> "AUTH" -> mt2fa + mt2fa -> "AUTHPEND" -> gameserver + mt2fa -> "AUTHFAIL" -> gameserver + mt2fa -> SMTP cookie -> mailbox + mailbox -> click link -> mt2fa (auth ok/auth fail) + + gameserver -> "AUTHSTAT" -> mt2fa + mt2fa -> AUTHOK + mt2fa -> AUTHFAIL + mt2fa -> AUTHPEND + +* fetching account info at login (optional auth) + + gameserver -> "ACCT" -> mt2fa + mt2fa -> ACCTOK -> gameserver + mt2fa -> ACCTFAIL -> gameserver + +* passreset request + + javascript form -> post -> mt2fa + mt2fa -> SMTP -> mailbox !confirmation + mailbox -> click link -> mt2fa + mt2fa -> SMTP -> mailbox !new password + + gameserver -> "UPDATES" -> mt2fa + mt2fa -> "UPDATE" -> gameserver + mt2fa -> "NOUPDATES" -> gameserver + +* email change + + javascript form -> post -> mt2fa + mt2fa -> SMTP -> mailbox !confirmation_old_mailbox + mailbox -> click link -> mt2fa + mt2fa -> SMTP -> mailbox !confirmation_new_mailbox + mailbox -> click link -> mt2fa + + gameserver -> "UPDATES" -> mt2fa + mt2fa -> "UPDATE" -> gameserver + mt2fa -> "NOUPDATES" -> gameserver + +* server enrollment + + gameserver -> SERVER -> mt2fa + mt2fa -> SERVERFAIL -> gameserver + mt2fa -> SERVERPEND -> gameserver + mt2fa -> SMTP -> mailbox !confirmation + mailbox -> click link -> mt2fa + + gameserver -> SERVERSTAT -> mt2fa + mt2fa -> SERVERFAIL -> gameserver + mt2fa -> SERVERPEND -> gameserver + mt2fa -> SERVEROK -> gameserver + + -> SERVERIP -> + SERVERIPFAIL + SERVERIPPEND + SERVERIPOK + +## Database Schema + +CREATE TABLE tokens ( + token TEXT NOT NULL PRIMARY KEY, -- secret + cookie TEXT NOT NULL, -- non-secret + created INTEGER NOT NULL, -- DATE first created + expiry INTEGER NOT NULL, -- timestamp when no longer valid + request TEXT NOT NULL, -- the JSON request context + confirmed BOOLEAN DEFAULT FALSE, -- whether a token has been confirmed by a user +) + +CREATE TABLE servers ( + server_id TEXT NOT NULL PRIMARY KEY, -- unique identifier, arbitrary string + created INTEGER NOT NULL, -- DATE first created + email TEXT NOT NULL, -- associated admin, for confirmation requests + data TEXT NOT NULL, -- JSON encoded data, expandable data format + ip TEXT NOT NULL, -- used to prevent token stealing/spoofing +) + +CREATE TABLE identities ( + email TEXT NOT NULL PRIMARY KEY, -- unique identifier, must be valid email + created INTEGER NOT NULL, -- DATE first created + data TEXT NOT NULL, -- JSON encoded data, expandable data format +) + +CREATE TABLE players ( + email TEXT NOT NULL, -- identity + name TEXT NOT NULL, -- local player name + server_id TEXT NOT NULL, -- unique server identity + created INTEGER NOT NULL, -- DATE created for this server + data TEXT NOT NULL, -- JSON encoded data, expandable data format +) + diff --git a/www/beauty.min.css b/www/beauty.min.css new file mode 100644 index 0000000..9811c0d --- /dev/null +++ b/www/beauty.min.css @@ -0,0 +1,2 @@ +*{margin:0;padding:0;box-sizing:border-box}body{font-family:"Segoe UI", "San Francisco", sans-serif;background-color:#f8f8f8;color:#333;line-height:1.6;max-width:95%;margin:0 auto}::-moz-selection{background-color:#2297e0;color:#fff}::selection{background-color:#2297e0;color:#fff}@media (min-width: 40rem){body{max-width:90%}}@media (min-width: 60rem){body{max-width:80%}}@media (min-width: 80rem){body{max-width:70%}}@media (min-width: 100rem){body{max-width:60%}}h1{margin:2rem 0;text-align:center}h2{margin:1.5rem 0}h3{margin:1rem 0}h4{margin:0.75rem 0}h5{margin:0.75rem 0}h6{margin:0.75rem 0}p{margin:1rem 0}li{list-style-position:inside}code,pre{background-color:#e4e4e4;color:#333;padding:0.125rem;border-radius:0.25rem;font-size:0.875rem}pre{white-space:pre-wrap;tab-size:2}kbd{background-color:#fff;color:#333;border:1px solid #ccc;padding:0.125rem;border-radius:0.25rem}blockquote{border-left:3px solid #999;padding:0.75rem 0 0.75rem 1rem;color:#777;background-color:#eee}footer{text-align:center;color:#888;padding:0.375rem;margin-top:3rem}a{color:#2297e0;text-decoration:none;transition:0.15s background-color}a:hover{background-color:#bfe1f6}a:active{background-color:#92ccf0}table,th,td{text-align:left;padding:0.2em;margin:0px;border:1px solid;border-collapse:collapse;} +.modal{display:none;position:fixed;z-index:1;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4);}.modal-content{background-color:#fefefe;margin:15% auto;padding:20px;border:1px solid #888;width:95%;}.close{color:#aaa;float:right;font-size:28px;font-weight:bold;}.close:hover,.close:focus{color:black;text-decoration:none;cursor:pointer;} diff --git a/www/confirm b/www/confirm new file mode 100644 index 0000000..3cbe552 --- /dev/null +++ b/www/confirm @@ -0,0 +1,11 @@ + + + MT2FA 2-Factor Authentication - Confirmation + + + +

Your request results:

+
Javascript is required for this webpage to function.
+ + + diff --git a/www/confirm.js b/www/confirm.js new file mode 100644 index 0000000..df12d1a --- /dev/null +++ b/www/confirm.js @@ -0,0 +1,39 @@ + +var urlParams; +(window.onpopstate = function () { + var match, + pl = /\+/g, // Regex for replacing addition symbol with a space + search = /([^&=]+)=?([^&]*)/g, + decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); }, + query = window.location.search.substring(1); + + urlParams = {}; + while (match = search.exec(query)) + urlParams[decode(match[1])] = decode(match[2]); +})(); + +// Let the user know JS is working by removing the "JS is needed" text +document.getElementById("c").innerHTML = "Submitting confirmation data..."; + +// Create JSON data to post +var data = {}; +data["token"] = urlParams["t"]; +data["request_type"] = "CONFIRM"; + +// POST it +var xhr = new XMLHttpRequest(); +xhr.open("POST", "/mt2fa", true); +xhr.setRequestHeader("Content-Type", 'application/json'); +xhr.send(JSON.stringify(data)) + +xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status == 200) { + // Parse reply and display msg to user + var data = JSON.parse(xhr.responseText); + if (data.result == "CONFIRMOK") { + document.getElementById("c").innerHTML = "OK: " + data.info; + } else { + document.getElementById("c").innerHTML = "ERROR: " + data.result + " - " + data.info; + } + } +};