Initial commit of MVP API

Includes Microsoft->Minecraft OAuth, simple server adding and invite
generation, and invite acceptance/RCON whitelisting.
This commit is contained in:
Gabriel Simmer 2022-07-05 11:10:18 +01:00
commit 7f1ba6222d
10 changed files with 1067 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
.env
.idea/
db.sqlite3

230
auth/auth.go Normal file
View file

@ -0,0 +1,230 @@
package auth
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
"io/ioutil"
"net/http"
"os"
"time"
"whitelistmanager/store"
)
const NoMinecraftOwnership = "minecraft is not owned"
type MinecraftLoginResponse struct {
Username string `json:"username"`
Roles []string `json:"roles"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type MinecraftProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Error string `json:"error"`
}
type XblResponse struct {
IssueInstant time.Time `json:"IssueInstant"`
NotAfter time.Time `json:"NotAfter"`
Token string `json:"Token"`
DisplayClaims DisplayClaims `json:"DisplayClaims"`
}
type Xui struct {
Uhs string `json:"uhs"`
}
type DisplayClaims struct {
Xui []Xui `json:"xui"`
}
type MinecraftAuth struct {
IdentityToken string `json:"identityToken"`
}
func LoginUrl() string {
oaConfig := &oauth2.Config{
ClientID: os.Getenv("AZURE_OAUTH_CLIENT_ID"),
ClientSecret: os.Getenv("AZURE_OAUTH_CLIENT_SECRET"),
Endpoint: endpoints.Microsoft,
Scopes: []string{"Xboxlive.signin", "Xboxlive.offline_access"},
}
return oaConfig.AuthCodeURL("foo")
}
func Authenticate(code string) (store.User, error) {
oaConfig := &oauth2.Config{
ClientID: os.Getenv("AZURE_OAUTH_CLIENT_ID"),
ClientSecret: os.Getenv("AZURE_OAUTH_CLIENT_SECRET"),
Endpoint: endpoints.Microsoft,
Scopes: []string{"Xboxlive.signin", "Xboxlive.offline_access"},
}
token, _ := oaConfig.Exchange(context.Background(), code)
client := oaConfig.Client(context.Background(), token)
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
Renegotiation: tls.RenegotiateOnceAsClient,
InsecureSkipVerify: true,
},
}
minecraftToken, userProfile, err := minecraftTokenExchange(token, client)
if err != nil {
return store.User{}, err
}
user := store.User{
Id: userProfile.ID,
Token: minecraftToken,
DisplayName: userProfile.Name,
RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry,
}
return user, nil
}
func xblTokenExchange(token *oauth2.Token, client *http.Client) (string, string, error) {
xblPayload := map[string]interface{}{
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT",
"Properties": map[string]interface{}{
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": "d=" + token.AccessToken,
},
}
encoded, err := json.Marshal(xblPayload)
if err != nil {
return "", "", err
}
request, err := http.NewRequest("POST", "https://user.auth.xboxlive.com/user/authenticate", bytes.NewReader(encoded))
if err != nil {
return "", "", err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("x-xbl-contract-version", "1")
resp, err := client.Do(request)
if err != nil {
return "", "", err
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
var xblData XblResponse
err = json.Unmarshal(b, &xblData)
if err != nil {
return "", "", err
}
return xblData.Token, xblData.DisplayClaims.Xui[0].Uhs, nil
}
func xstsTokenExchange(xblToken string, client *http.Client) (string, error) {
body := map[string]interface{}{
"Properties": map[string]interface{}{
"SandboxId": "RETAIL",
"UserTokens": []string{
xblToken,
},
},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT",
}
encoded, err := json.Marshal(body)
if err != nil {
return "", err
}
request, err := http.NewRequest("POST", "https://xsts.auth.xboxlive.com/xsts/authorize", bytes.NewReader(encoded))
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("x-xbl-contract-version", "1")
resp, err := client.Do(request)
if err != nil {
return "", err
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
var jsonResponse map[string]interface{}
err = json.Unmarshal(b, &jsonResponse)
if err != nil {
return "", err
}
return jsonResponse["Token"].(string), nil
}
// Exchanges our Microsoft OAuth token for Xbox Live -> XSTS -> Mojang
func minecraftTokenExchange(token *oauth2.Token, client *http.Client) (string, MinecraftProfile, error) {
xblToken, userHash, err := xblTokenExchange(token, client)
if err != nil {
return "", MinecraftProfile{}, err
}
xstsToken, err := xstsTokenExchange(xblToken, client)
if err != nil {
return "", MinecraftProfile{}, err
}
body := MinecraftAuth{
IdentityToken: fmt.Sprintf("XBL3.0 x=%s;%s", userHash, xstsToken),
}
encoded, err := json.Marshal(body)
if err != nil {
return "", MinecraftProfile{}, err
}
request, err := http.NewRequest("POST", "https://api.minecraftservices.com/authentication/login_with_xbox", bytes.NewReader(encoded))
if err != nil {
return "", MinecraftProfile{}, err
}
response, err := client.Do(request)
if err != nil {
return "", MinecraftProfile{}, err
}
var mcr MinecraftLoginResponse
err = json.NewDecoder(response.Body).Decode(&mcr)
if err != nil {
return "", MinecraftProfile{}, err
}
verificationReq, err := http.NewRequest("GET", "https://api.minecraftservices.com/minecraft/profile", nil)
if err != nil {
return "", MinecraftProfile{}, err
}
verificationReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", mcr.AccessToken))
resp, err := client.Do(verificationReq)
if err != nil {
return "", MinecraftProfile{}, err
}
var mcp MinecraftProfile
err = json.NewDecoder(resp.Body).Decode(&mcp)
if err != nil {
return "", MinecraftProfile{}, err
}
if mcp.Error != "" && mcp.ID == "" {
// User does not own the game, or no ID comes back
return "", MinecraftProfile{}, errors.New(NoMinecraftOwnership)
}
return mcr.AccessToken, mcp, nil
}

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module whitelistmanager
go 1.18
require (
github.com/Kelwing/mc-rcon v0.0.0-20220214194105-bec8dcbccc3f
github.com/alexedwards/flow v0.0.0-20220607190737-c48a87f2b4c4
github.com/google/uuid v1.3.0
github.com/mattn/go-sqlite3 v1.14.14
golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0
)
require (
github.com/golang/protobuf v1.5.2 // indirect
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
)

31
go.sum Normal file
View file

@ -0,0 +1,31 @@
github.com/Kelwing/mc-rcon v0.0.0-20220214194105-bec8dcbccc3f h1:ySopb0/agJ4rwNsIthx9uguIlbUEnjSVKcao3n9ZPEY=
github.com/Kelwing/mc-rcon v0.0.0-20220214194105-bec8dcbccc3f/go.mod h1:wlXY/EO9CBjMViEh95JOQhp//RMnHs4ZUBhYhx43LdM=
github.com/alexedwards/flow v0.0.0-20220607190737-c48a87f2b4c4 h1:tF8vI7d4pKpByHydzNUDsOuZMOqmuyt+8/HuSN0hzGA=
github.com/alexedwards/flow v0.0.0-20220607190737-c48a87f2b4c4/go.mod h1:1rjOQiOqQlmMdUMuvlJFjldqTnE/tQULE7qPIu4aq3U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 h1:VnGaRqoLmqZH/3TMLJwYCEWkR4j1nuIU1U9TvbqsDUw=
golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

79
invite/invite.go Normal file
View file

@ -0,0 +1,79 @@
package invite
import (
"crypto/rand"
"database/sql"
"errors"
"math/big"
"whitelistmanager/store"
)
const NotOwnerofServer = "user is not owner of server"
type Manager struct {
store store.Storer
}
type InviteManager interface {
Create(in store.Invite, user store.User) (string, error)
RemainingUses(in store.Invite) (int, error)
}
func NewManager(db store.Storer) *Manager {
return &Manager{store: db}
}
func (i *Manager) Create(in store.Invite, user store.User) (string, error) {
server, err := i.store.GetServer(in.Server.Id)
if server.Owner.Id != user.Id {
return "", errors.New(NotOwnerofServer)
}
token, err := i.createToken()
if err != nil {
return "", err
}
// If we have an unlikely collision, generate a new token.
_, err = i.store.GetInvite(token)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return "", err
}
} else {
token, err = i.createToken()
if err != nil {
return "", err
}
}
in.Token = token
in.Creator = user
return token, i.store.SaveInvite(in)
}
func (i *Manager) createToken() (string, error) {
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-"
ret := make([]byte, 8)
for i := 0; i < 8; i++ {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
return "", err
}
ret[i] = letters[num.Int64()]
}
return string(ret), nil
}
func (i *Manager) RemainingUses(in store.Invite) (int, error) {
if in.Unlimited {
return 1, nil
}
logs, err := i.store.InviteLog(in)
if err != nil {
return 0, nil
}
return in.Uses - len(logs), nil
}

43
main.go Normal file
View file

@ -0,0 +1,43 @@
package main
import (
"github.com/alexedwards/flow"
"log"
"net/http"
"whitelistmanager/store"
"whitelistmanager/transport"
)
func main() {
mux := flow.New()
db, err := store.Open()
if err != nil {
panic(err)
}
handlers := transport.New(db)
// Auth endpoints
mux.Group(func(mux *flow.Mux) {
mux.HandleFunc("/api/v1/auth/redirect", handlers.AuthRedirect)
mux.HandleFunc("/api/v1/auth/callback", handlers.AuthCallback)
})
// API endpoints
mux.Group(func(mux *flow.Mux) {
mux.Use(handlers.Cors)
mux.Use(handlers.SessionAuth)
mux.HandleFunc("/api/v1/me", handlers.CurrentUser, "GET")
mux.HandleFunc("/api/v1/invites", handlers.CreateInvite, "POST")
mux.HandleFunc("/api/v1/invite/:id", handlers.GetInvite, "GET", "DELETE")
mux.HandleFunc("/api/v1/invite/:id/accept", handlers.AcceptInvite, "POST")
mux.HandleFunc("/api/v1/servers", handlers.Server, "GET", "POST")
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
r.Header.Set("Content-Type", "application/json")
w.Write([]byte(`{"duck": "quacks"}`))
})
log.Fatal(http.ListenAndServe("0.0.0.0:8080", mux))
}

35
minecraft/minecraft.go Normal file
View file

@ -0,0 +1,35 @@
package minecraft
import (
"errors"
"fmt"
mcrcon "github.com/Kelwing/mc-rcon"
"whitelistmanager/store"
)
const (
RconConnectionError = "rcon connection failed"
RconAuthError = "failed to authenticate with rcon server"
RconCommandError = "command failed"
)
func Whitelist(user string, server store.Server) (string, error) {
conn := new(mcrcon.MCConn)
err := conn.Open(server.Rcon.Address, server.Rcon.Password)
if err != nil {
return "", errors.New(RconConnectionError)
}
defer conn.Close()
err = conn.Authenticate()
if err != nil {
return "", errors.New(RconAuthError)
}
command := fmt.Sprintf("whitelist add %s", user)
resp, err := conn.SendCommand(command)
if err != nil {
return "", errors.New(RconCommandError)
}
return resp, nil
}

303
store/database.go Normal file
View file

@ -0,0 +1,303 @@
package store
import (
"database/sql"
"embed"
"errors"
"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
"log"
"os"
"time"
)
//go:embed database.sql
var migrations embed.FS
const SessionExpired = "session expired"
type Store struct {
database *sql.DB
}
type Storer interface {
SaveInvite(invite Invite) error
GetInvite(token string) (Invite, error)
GetServer(id string) (Server, error)
SaveServer(server Server) error
GetUserServers(user User) ([]Server, error)
LogInviteUse(user string, invite Invite) error
InviteLog(invite Invite) ([]InviteLog, error)
GetUser(uid string) (User, error)
SaveUser(user User) error
SaveSession(token string, user User) error
SessionUser(token string) (User, error)
}
type Invite struct {
Token string `json:"token"`
Creator User `json:"creator"`
Server Server `json:"server"`
Uses int `json:"uses"`
Unlimited bool `json:"unlimited"`
}
type Server struct {
Id string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Rcon Rcon `json:"rcon,omitempty"`
Owner User `json:"-"`
}
type User struct {
Id string `json:"id,omitempty"`
Token string `json:"-"`
DisplayName string `json:"display_name"`
RefreshToken string `json:"-"`
TokenExpiry time.Time `json:"-"`
}
type Rcon struct {
Address string `json:"address,omitempty"`
Password string `json:"password,omitempty"`
}
type Session struct {
Token string
UID string
Expiry time.Time
}
func Open() (*Store, error) {
database := os.Getenv("WLM_DATABASE_PATH")
if database == "" {
database = "db.sqlite3"
}
if _, err := os.Stat(database); errors.Is(err, os.ErrNotExist) {
log.Printf("No database found at %s, creating", database)
_, err := os.Create(database)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite3", database)
if err != nil {
return nil, err
}
initialSetup, err := migrations.ReadFile("database.sql")
if err != nil {
return nil, err
}
_, err = db.Exec(string(initialSetup))
if err != nil {
return nil, err
}
log.Printf("Database created at %s", database)
db.Close()
}
db, err := sql.Open("sqlite3", database)
if err != nil {
return nil, err
}
return &Store{database: db}, nil
}
func (s *Store) SaveInvite(invite Invite) error {
q, err := s.database.Prepare("INSERT INTO invites (token, creator, server, uses, unlimited) VALUES ($1, $2, $3, $4, $5)")
if err != nil {
return err
}
_, err = q.Exec(invite.Token, invite.Creator.Id, invite.Server.Id, invite.Uses, invite.Unlimited)
if err != nil {
return err
}
return nil
}
func (s *Store) GetInvite(token string) (Invite, error) {
if token == "" {
return Invite{}, sql.ErrNoRows
}
q := s.database.QueryRow("SELECT * FROM invites WHERE token=$1", token)
var in Invite
err := q.Scan(&in.Token, &in.Creator.Id, &in.Server.Id, &in.Uses, &in.Unlimited)
if err != nil {
return Invite{}, err
}
in.Server, err = s.GetServer(in.Server.Id)
if err != nil {
return Invite{}, err
}
in.Server.Rcon = Rcon{}
q = s.database.QueryRow("SELECT display_name FROM users WHERE id=$1", in.Creator.Id)
err = q.Scan(&in.Creator.DisplayName)
if err != nil {
return Invite{}, err
}
return in, nil
}
func (s *Store) LogInviteUse(user string, invite Invite) error {
q, err := s.database.Prepare("INSERT INTO invite_log (entry_id, invite, user) VALUES ($1, $2, $3)")
if err != nil {
return err
}
entryId := uuid.New().String()
_, err = q.Exec(entryId, invite.Token, user)
return err
}
type InviteLog struct {
EntryID string
Invite string
User string
}
func (s *Store) InviteLog(invite Invite) ([]InviteLog, error) {
q, err := s.database.Query("SELECT * FROM invite_log WHERE invite=$1", invite.Token)
if err != nil {
return []InviteLog{}, nil
}
var log []InviteLog
for q.Next() {
var logEntry InviteLog
err := q.Scan(&logEntry.EntryID, &logEntry.Invite, &logEntry.User)
if err != nil {
continue
}
log = append(log, logEntry)
}
return log, nil
}
func (s *Store) GetServer(id string) (Server, error) {
if id == "" {
return Server{}, sql.ErrNoRows
}
q := s.database.QueryRow("SELECT * FROM servers WHERE id=$1", id)
var serv Server
err := q.Scan(&serv.Id, &serv.Name, &serv.Address, &serv.Rcon.Address, &serv.Rcon.Password, &serv.Owner.Id)
if err != nil {
return Server{}, err
}
return serv, nil
}
func (s *Store) GetUserServers(user User) ([]Server, error) {
q, err := s.database.Query("SELECT id,address,name,rcon_address FROM servers WHERE owner=$1", user.Id)
if err != nil {
return []Server{}, nil
}
var servers []Server
for q.Next() {
var server Server
err := q.Scan(&server.Id, &server.Address, &server.Name, &server.Rcon.Address)
if err != nil {
continue
}
servers = append(servers, server)
}
return servers, nil
}
func (s *Store) SaveServer(server Server) error {
q, err := s.database.Prepare("INSERT INTO servers (id, address, name, rcon_address, rcon_password, owner) VALUES ($1, $2, $3, $4, $5, $6)")
if err != nil {
return err
}
_, err = q.Exec(server.Id, server.Address, server.Name, server.Rcon.Address, server.Rcon.Password, server.Owner.Id)
if err != nil {
return err
}
return nil
}
func (s *Store) GetUser(uid string) (User, error) {
q := s.database.QueryRow("SELECT * FROM users WHERE id=$1", uid)
var user User
var tokenExpiry string
err := q.Scan(&user.Id, &user.Token, &user.DisplayName, &user.RefreshToken, &tokenExpiry)
if err != nil {
return User{}, err
}
user.TokenExpiry, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", tokenExpiry)
if err != nil {
return User{}, err
}
return user, nil
}
func (s *Store) SaveUser(user User) error {
existingUser, err := s.GetUser(user.Id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
q, err := s.database.Prepare("INSERT INTO users (id, token, display_name, refresh_token, token_expiry) VALUES ($1, $2, $3, $4, $5)")
if err != nil {
return err
}
_, err = q.Exec(user.Id, user.Token, user.DisplayName, user.RefreshToken, user.TokenExpiry)
if err != nil {
return err
}
return nil
} else {
return err
}
}
q, err := s.database.Prepare("UPDATE users SET token=$2, display_name=$3, refresh_token=$4, token_expiry=$5 WHERE id=$1")
if err != nil {
return err
}
_, err = q.Exec(existingUser.Id, user.Token, user.DisplayName, user.RefreshToken, user.TokenExpiry)
return err
}
func (s *Store) SaveSession(token string, user User) error {
q, err := s.database.Prepare("INSERT INTO sessions (token, uid, expiry) VALUES ($1, $2, $3)")
if err != nil {
return err
}
// Expire in 30 days.
_, err = q.Exec(token, user.Id, time.Now().Add(720*time.Hour))
return err
}
func (s *Store) SessionUser(token string) (User, error) {
q := s.database.QueryRow("SELECT * FROM sessions WHERE token=$1", token)
var sess Session
var sessExpiry string
err := q.Scan(&sess.Token, &sess.UID, &sessExpiry)
if err != nil {
return User{}, err
}
sess.Expiry, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", sessExpiry)
if err != nil {
return User{}, err
}
if sess.Expiry.Before(time.Now()) {
return User{}, errors.New(SessionExpired)
}
return s.GetUser(sess.UID)
}

47
store/database.sql Normal file
View file

@ -0,0 +1,47 @@
-- noinspection SqlNoDataSourceInspectionForFile
CREATE TABLE `invites` (
`token` TEXT NOT NULL,
`creator` TEXT NOT NULL,
`server` TEXT NOT NULL,
`uses` INT NOT NULL,
`unlimited` INTEGER NOT NULL,
PRIMARY KEY (`token`),
FOREIGN KEY (`server`) REFERENCES servers(`id`),
FOREIGN KEY (`creator`) REFERENCES users(`id`)
) STRICT;
CREATE TABLE `servers` (
`id` TEXT NOT NULL,
`name` TEXT NOT NULL,
`address` TEXT NOT NULL,
`rcon_address` TEXT NOT NULL,
`rcon_password` TEXT NOT NULL,
`owner` TEXT NOT NULL,
PRIMARY KEY (`id`)
) STRICT;
CREATE TABLE `users` (
`id` TEXT NOT NULL,
`token` TEXT NOT NULL,
`display_name` TEXT NOT NULL,
`refresh_token` TEXT NOT NULL,
`token_expiry` TEXT NOT NULL,
PRIMARY KEY (`id`)
) STRICT;
CREATE TABLE `sessions` (
`token` TEXT NOT NULL,
`uid` TEXT NOT NULL,
`expiry` TEXT NOT NULL,
PRIMARY KEY (`token`),
FOREIGN KEY (`uid`) REFERENCES users(`id`)
) STRICT;
CREATE TABLE `invite_log` (
`entry_id` TEXT NOT NULL,
`invite` TEXT NOT NULL,
`user` TEXT NOT NULL,
PRIMARY KEY (`entry_id`),
FOREIGN KEY (`invite`) REFERENCES invites(`token`)
)

256
transport/http.go Normal file
View file

@ -0,0 +1,256 @@
package transport
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/alexedwards/flow"
"github.com/google/uuid"
"log"
"net/http"
"whitelistmanager/auth"
"whitelistmanager/invite"
"whitelistmanager/minecraft"
"whitelistmanager/store"
)
type Handler struct {
store store.Storer
manager invite.InviteManager
}
type Handle interface {
AuthMiddleware(next http.Handler) http.Handler
Cors(next http.Handler) http.Handler
CurrentUser(w http.ResponseWriter, r *http.Request)
CreateInvite(w http.ResponseWriter, r *http.Request)
GetInvite(w http.ResponseWriter, r *http.Request)
AcceptInvite(w http.ResponseWriter, r *http.Request)
AuthRedirect(w http.ResponseWriter, r *http.Request)
AuthCallback(w http.ResponseWriter, r *http.Request)
CreateServer(w http.ResponseWriter, r *http.Request)
}
func New(store store.Storer) *Handler {
im := invite.NewManager(store)
return &Handler{
store: store,
manager: im,
}
}
func (h *Handler) Cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
w.Header().Set("Access-Control-Allow-Methods", "*")
next.ServeHTTP(w, r)
})
}
func (h *Handler) SessionAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := r.Cookie("session")
if err != nil {
log.Println(err)
http.Redirect(w, r, "/api/v1/auth/redirect", http.StatusTemporaryRedirect)
return
}
user, err := h.store.SessionUser(session.Value)
if err != nil {
log.Println(err)
http.Redirect(w, r, "/api/v1/auth/redirect", http.StatusTemporaryRedirect)
return
}
ctx := context.WithValue(r.Context(), "user", user)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
type invitePayload struct {
Server string `json:"server"`
Unlimited bool `json:"unlimited"`
Uses int `json:"uses"`
}
func (h *Handler) CreateInvite(w http.ResponseWriter, r *http.Request) {
var i invitePayload
err := json.NewDecoder(r.Body).Decode(&i)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user := r.Context().Value("user").(store.User)
in, err := h.manager.Create(store.Invite{
Server: store.Server{Id: i.Server},
Unlimited: i.Unlimited,
Uses: i.Uses,
}, user)
if err != nil {
if err.Error() == invite.NotOwnerofServer {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte(in))
}
func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) {
token := flow.Param(r.Context(), "id")
in, err := h.store.GetInvite(token)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "invalid invite", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = json.NewEncoder(w).Encode(in)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
token := flow.Param(r.Context(), "id")
in, err := h.store.GetInvite(token)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "invalid invite", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
remainingUse, err := h.manager.RemainingUses(in)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if remainingUse == 0 {
http.Error(w, "no more uses remaining for invite", http.StatusForbidden)
return
}
server, err := h.store.GetServer(in.Server.Id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
user := r.Context().Value("user").(store.User)
log.Println(user.DisplayName)
resp, err := minecraft.Whitelist(user.DisplayName, server)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if resp == fmt.Sprintf("Added %s to the whitelist", user.DisplayName) {
err = h.store.LogInviteUse(user.DisplayName, in)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Write([]byte(resp))
}
func (h *Handler) AuthRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, auth.LoginUrl(), http.StatusTemporaryRedirect)
}
func (h *Handler) AuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query()["code"][0]
user, err := auth.Authenticate(code)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = h.store.SaveUser(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sessToken, err := generateSessionToken()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessToken,
HttpOnly: true,
Path: "/",
})
err = h.store.SaveSession(sessToken, user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
func generateSessionToken() (string, error) {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), err
}
func (h *Handler) Server(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(store.User)
if r.Method == http.MethodGet {
servers, err := h.store.GetUserServers(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(servers)
return
}
var server store.Server
err := json.NewDecoder(r.Body).Decode(&server)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
server.Id = uuid.New().String()
server.Owner.Id = user.Id
err = h.store.SaveServer(server)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "created server %s", server.Id)
}
func (h *Handler) CurrentUser(w http.ResponseWriter, r *http.Request) {
value := r.Context().Value("user")
if value == nil {
http.Error(w, "", http.StatusForbidden)
return
}
user := value.(store.User)
w.Write([]byte(user.DisplayName))
}