Initial commit.

This commit is contained in:
Auke Kok 2017-12-23 19:05:04 -08:00
commit 5cffe2b8ae
5 changed files with 1162 additions and 0 deletions

820
main.go Normal file
View File

@ -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)
}

290
readme.md Normal file
View File

@ -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 <sofar@foo-projects.org>
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
)

2
www/beauty.min.css vendored Normal file
View File

@ -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;}

11
www/confirm Normal file
View File

@ -0,0 +1,11 @@
<html>
<head>
<title>MT2FA 2-Factor Authentication - Confirmation</title>
<link rel="stylesheet" href="beauty.min.css" />
</head>
<body>
<h3>Your request results:</h3>
<pre><div id="c">Javascript is required for this webpage to function.</div></pre>
<script src="/confirm.js"></script>
</body>
</html>

39
www/confirm.js Normal file
View File

@ -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;
}
}
};